Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit ad35cc8bacba258d1b433a52de9bbc0f33172bb7


Parents : 59d0d3b
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-06-08T08:06:01-05:00

feat(messages): improve file attachment handling and add outbound transfer progress features

- Introduced new utility functions for file attachment checks in messages.
- Updated message rendering logic to accommodate file attachments and outbound transfer progress.
- Added new components for displaying outbound transfer progress and message reactions.
- Enhanced conversation message entry and list components for better user experience with file attachments and reactions.
- Improved handling of message states and synchronization in the backend.

Changes

26 files changed, 2008 insertions(+), 976 deletions(-)


Diff

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 19db2374..30ba849d 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -115,6 +115,8 @@ from meshchatx.src.backend.meshchat_utils import (
has_attachments,
hex_identifier_to_bytes,
interval_action_due,
+ propagation_sync_idle_like,
+ propagation_sync_is_terminal,
message_fields_have_attachments,
normalize_hex_identifier,
parse_bool_query_param,
@@ -2400,7 +2402,8 @@ class ReticulumMeshChat:
last_synced_at = (
ctx.config.lxmf_preferred_propagation_node_last_synced_at.get()
)
- should_sync = interval_action_due(
+ outbound_node = router.get_outbound_propagation_node() if router else None
+ should_sync = outbound_node is not None and interval_action_due(
auto_sync_interval_seconds is not None
and auto_sync_interval_seconds > 0,
last_synced_at,
@@ -2408,22 +2411,31 @@ class ReticulumMeshChat:
time.time(),
)
- # sync
if should_sync and sync_start_time is None:
- sync_start_time = time.monotonic()
- await self.sync_propagation_nodes(context=ctx)
+ started = await self.sync_propagation_nodes(context=ctx)
+ if started:
+ sync_start_time = time.monotonic()
- # stuck-sync watchdog and completion detection
if sync_start_time is not None and router:
state = router.propagation_transfer_state
- pr_complete = getattr(router, "PR_COMPLETE", None)
- if state in {router.PR_IDLE, pr_complete}:
+ elapsed = time.monotonic() - sync_start_time
+ path_stuck = (
+ state == router.PR_PATH_REQUESTED
+ and outbound_node is not None
+ and not RNS.Transport.has_path(outbound_node)
+ and elapsed > 45.0
+ )
+ if propagation_sync_is_terminal(state):
+ if state not in {router.PR_IDLE, router.PR_COMPLETE}:
+ self.stop_propagation_node_sync(context=ctx)
+ with contextlib.suppress(Exception):
+ router.propagation_transfer_state = router.PR_IDLE
ctx.config.lxmf_preferred_propagation_node_last_synced_at.set(
int(time.time())
)
await self.send_config_to_websocket_clients(context=ctx)
sync_start_time = None
- elif time.monotonic() - sync_start_time > 120:
+ elif path_stuck or elapsed > 120:
self.stop_propagation_node_sync(context=ctx)
with contextlib.suppress(Exception):
router.propagation_transfer_state = router.PR_IDLE
@@ -14260,35 +14272,38 @@ class ReticulumMeshChat:
async def sync_propagation_nodes(self, context=None, force=False):
ctx = context or self.current_context
if not ctx:
- return
+ return False
router = ctx.message_router
if not router:
- return
+ return False
- # Prevent overlapping auto-syncs from piling up requests.
- # A manual/API call can force a restart by cancelling the old sync first.
- if router.propagation_transfer_state != router.PR_IDLE:
- if not force:
- return
+ outbound_node = router.get_outbound_propagation_node()
+ if outbound_node is None:
+ return False
+
+ state = router.propagation_transfer_state
+ if propagation_sync_idle_like(state):
+ if state == router.PR_COMPLETE:
+ with contextlib.suppress(Exception):
+ router.propagation_transfer_state = router.PR_IDLE
+ router.propagation_transfer_progress = 0.0
+ elif not force:
+ return False
+ else:
self.stop_propagation_node_sync(context=ctx)
- # Give the router a moment to settle back to idle
settle_deadline = time.monotonic() + 5.0
while time.monotonic() < settle_deadline:
if router.propagation_transfer_state == router.PR_IDLE:
break
await asyncio.sleep(0.2)
else:
- # Force reset if it didn't settle
with contextlib.suppress(Exception):
router.propagation_transfer_state = router.PR_IDLE
self._begin_propagation_sync_metrics(context=ctx)
- # update last synced at timestamp
ctx.config.lxmf_preferred_propagation_node_last_synced_at.set(int(time.time()))
-
- outbound_node = router.get_outbound_propagation_node()
local_propagation_destination = getattr(
router,
"propagation_destination",
@@ -14307,15 +14322,15 @@ class ReticulumMeshChat:
router.propagation_transfer_progress = 1.0
router.propagation_transfer_last_result = 0
await self.send_config_to_websocket_clients(context=ctx)
- return
+ return True
# Kick off the LXMF request on a worker thread. Identity.recall and link
# setup can block on multiprocessing pipes; running inline would stall the
# HTTP handler and race with cancel_propagation_node_requests (EOFError).
asyncio.create_task(self._request_propagation_node_messages(context=ctx))
- # send config to websocket clients (used to tell ui last synced at)
await self.send_config_to_websocket_clients(context=ctx)
+ return True
# helper to parse boolean from possible string or bool
@staticmethod

diff --git a/meshchatx/src/backend/auto_propagation_manager.py b/meshchatx/src/backend/auto_propagation_manager.py
index 5745d4d6..f1c02c36 100644
--- a/meshchatx/src/backend/auto_propagation_manager.py
+++ b/meshchatx/src/backend/auto_propagation_manager.py
@@ -19,6 +19,7 @@ _PROP_FAILURE_STATES = frozenset(
LXMRouter.PR_NO_IDENTITY_RCVD,
LXMRouter.PR_NO_ACCESS,
LXMRouter.PR_FAILED,
+ LXMRouter.PR_PATH_TIMEOUT,
},
)

diff --git a/meshchatx/src/backend/meshchat_utils.py b/meshchatx/src/backend/meshchat_utils.py
index 9f1e232b..de00b771 100644
--- a/meshchatx/src/backend/meshchat_utils.py
+++ b/meshchatx/src/backend/meshchat_utils.py
@@ -78,6 +78,29 @@ def has_attachments(lxmf_fields: dict) -> bool:
return False
+_PROPAGATION_SYNC_TERMINAL_STATES = frozenset(
+ {
+ LXMRouter.PR_IDLE,
+ LXMRouter.PR_COMPLETE,
+ LXMRouter.PR_NO_PATH,
+ LXMRouter.PR_LINK_FAILED,
+ LXMRouter.PR_TRANSFER_FAILED,
+ LXMRouter.PR_NO_IDENTITY_RCVD,
+ LXMRouter.PR_NO_ACCESS,
+ LXMRouter.PR_FAILED,
+ LXMRouter.PR_PATH_TIMEOUT,
+ },
+)
+
+
+def propagation_sync_is_terminal(state) -> bool:
+ return state in _PROPAGATION_SYNC_TERMINAL_STATES
+
+
+def propagation_sync_idle_like(state) -> bool:
+ return state in {LXMRouter.PR_IDLE, LXMRouter.PR_COMPLETE}
+
+
def convert_propagation_node_state_to_string(state):
state_map = {
LXMRouter.PR_IDLE: "idle",

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 3fdc4892..893fa0be 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -972,6 +972,10 @@ export default {
if (tg === "true" || tg === "false") {
GlobalState.messageTimestampGroupingEnabled = tg === "true";
}
+ const tp = localStorage.getItem("meshchatx_outbound_transfer_progress_enabled");
+ if (tp === "true" || tp === "false") {
+ GlobalState.outboundTransferProgressEnabled = tp === "true";
+ }
} catch {
// ignore
}

diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index 9e85a3ef..c8aa6c39 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -1083,7 +1083,7 @@
</button>
<button
- v-if="currentStep < totalSteps"
+ v-if="showFooterContinue"
type="button"
class="tutorial-action-btn tutorial-action-btn-primary"
:disabled="currentStep === 2 && identityImportInProgress"
@@ -2225,7 +2225,7 @@
</button>
<button
- v-if="currentStep < totalSteps"
+ v-if="showFooterContinue"
type="button"
class="tutorial-action-btn tutorial-action-btn-primary"
:disabled="currentStep === 2 && identityImportInProgress"
@@ -2384,6 +2384,12 @@ export default {
bootstrapSelectedLabels() {
return this.selectedBootstrapKeys.map((k) => this.bootstrapDisplayLabelForKey(k)).filter(Boolean);
},
+ showFooterContinue() {
+ if (this.currentStep === 3 || this.currentStep === 4) {
+ return false;
+ }
+ return this.currentStep < this.totalSteps;
+ },
},
watch: {
communityInterfaces() {
@@ -2713,10 +2719,10 @@ export default {
const payload = {
discover_interfaces: true,
autoconnect_discovered_interfaces: 3,
- default_bootstrap_only: true,
+ default_bootstrap_only: false,
};
await window.api.patch(`/api/v1/reticulum/discovery`, payload);
- this.defaultBootstrapOnly = true;
+ this.defaultBootstrapOnly = false;
ToastUtils.success(this.$t("tutorial.discovery_enabled"));
this.connectionMode = "discovery";
this.currentStep = 4;
@@ -2746,7 +2752,10 @@ export default {
GlobalState.hasPendingInterfaceChanges = true;
GlobalState.modifiedInterfaceNames.add("Local Network");
ToastUtils.success(this.$t("tutorial.local_added"));
- await this.reloadReticulum();
+ const reloaded = await this.reloadReticulum();
+ if (!reloaded) {
+ return;
+ }
this.connectionMode = "local";
this.currentStep = 5;
} catch (e) {
@@ -2992,8 +3001,11 @@ export default {
await window.api.patch("/api/v1/reticulum/discovery", {
default_bootstrap_only: value === true,
});
+ this.defaultBootstrapOnly = value === true;
} catch (e) {
console.error("Failed to save default_bootstrap_only:", e);
+ ToastUtils.error(this.$t("tutorial.failed_save_bootstrap_only"));
+ this.defaultBootstrapOnly = !value;
}
},
async confirmBootstraps() {
@@ -3029,13 +3041,18 @@ export default {
ToastUtils.error(e.response?.data?.message || this.$t("tutorial.failed_add_bootstrap"));
}
}
- if (added > 0) {
- this.interfaceAddedViaTutorial = true;
- ToastUtils.success(this.$t("tutorial.bootstrap_added", { count: added }));
- await this.reloadReticulum();
+ if (added === 0) {
+ ToastUtils.warning(this.$t("tutorial.failed_add_bootstrap_none"));
+ this.addingBootstraps = false;
+ return;
}
+ this.interfaceAddedViaTutorial = true;
+ ToastUtils.success(this.$t("tutorial.bootstrap_added", { count: added }));
+ const reloaded = await this.reloadReticulum();
this.addingBootstraps = false;
- this.currentStep = 5;
+ if (reloaded) {
+ this.currentStep = 5;
+ }
},
skipBootstraps() {
this.currentStep = 5;
@@ -3046,11 +3063,14 @@ export default {
await window.api.patch("/api/v1/config", {
lxmf_preferred_propagation_node_auto_select: true,
});
- ToastUtils.success("Auto-propagation enabled");
+ if (GlobalState.config) {
+ GlobalState.config.lxmf_preferred_propagation_node_auto_select = true;
+ }
+ ToastUtils.success(this.$t("tutorial.auto_propagation_enabled"));
this.nextStep();
} catch (e) {
console.error("Failed to enable auto-propagation:", e);
- ToastUtils.error("Failed to enable auto-propagation");
+ ToastUtils.error(e.response?.data?.message || this.$t("tutorial.failed_enable_propagation"));
} finally {
this.savingPropagation = false;
}
@@ -3104,29 +3124,6 @@ export default {
query: { view: "discovered" },
});
},
- async selectCommunityInterface(iface) {
- try {
- await window.api.post("/api/v1/reticulum/interfaces/add", {
- name: iface.name,
- type: iface.type,
- target_host: iface.target_host,
- target_port: iface.target_port,
- enabled: true,
- });
- ToastUtils.success(`Added interface: ${iface.name}`);
-
- this.interfaceAddedViaTutorial = true;
-
- // track change
- GlobalState.hasPendingInterfaceChanges = true;
- GlobalState.modifiedInterfaceNames.add(iface.name);
-
- this.nextStep();
- } catch (e) {
- console.error(e);
- ToastUtils.error(e.response?.data?.message || "Failed to add interface");
- }
- },
gotoAddInterface() {
if (!this.isPage) {
this.visible = false;
@@ -3152,8 +3149,18 @@ export default {
},
nextStep() {
if (this.currentStep >= this.totalSteps) return;
- if (this.currentStep === 3 && this.connectionMode !== "discovery") {
- this.currentStep = 5;
+ if (this.currentStep === 3) {
+ if (!this.connectionMode) {
+ ToastUtils.warning(this.$t("tutorial.connect_mode_required"));
+ return;
+ }
+ if (this.connectionMode !== "discovery") {
+ this.currentStep = 5;
+ return;
+ }
+ }
+ if (this.currentStep === 4) {
+ ToastUtils.warning(this.$t("tutorial.bootstrap_pick_at_least_one"));
return;
}
this.currentStep++;
@@ -3190,7 +3197,10 @@ export default {
},
async finishTutorial() {
if (GlobalState.hasPendingInterfaceChanges) {
- await this.reloadReticulum();
+ const reloaded = await this.reloadReticulum();
+ if (!reloaded) {
+ return;
+ }
}
if (this.identityImportedHash && this.identityImportedHash !== this.originalIdentityHash) {
try {
@@ -3205,8 +3215,8 @@ export default {
return;
}
}
+ await this.markSeen();
this.visible = false;
- this.markSeen();
if (this.interfaceAddedViaTutorial) {
ToastUtils.success(this.$t("tutorial.ready_finished"));
}

diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
index 3ab0a09d..f14809fe 100644
--- a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue
@@ -3,7 +3,7 @@
<template>
<div
v-if="entry.type === 'imageGroup'"
- class="flex flex-col max-w-[85%] sm:max-w-[75%] md:max-lg:max-w-[70%] lg:max-w-[65%] mb-2 group min-w-0"
+ class="flex flex-col max-w-[85%] sm:max-w-[75%] md:max-lg:max-w-[70%] lg:max-w-[65%] mb-2 group min-w-0 shrink-0 [overflow-anchor:none]"
:class="{
'ml-auto items-end': entry.items[0].is_outbound,
'mr-auto items-start': !entry.items[0].is_outbound,
@@ -11,148 +11,175 @@
@contextmenu.prevent="cv.onMessageContextMenu($event, entry.items[0], false)"
>
<div
- class="relative w-full max-w-[min(280px,85vw)] rounded-2xl overflow-hidden ring-1 ring-black/10 dark:ring-white/10 shadow-md mb-1.5"
- :class="entry.items[0].is_outbound ? 'ml-auto' : 'mr-auto'"
- @click.stop="cv.onChatItemClick(entry.items[0])"
- @contextmenu.prevent.stop="cv.onMessageContextMenu($event, entry.items[0], true)"
+ class="relative w-full max-w-[min(280px,85vw)] mb-1.5"
+ :class="[
+ entry.items[0].is_outbound ? 'ml-auto' : 'mr-auto',
+ (entry.items[0].lxmf_message.reactions?.length ?? 0) > 0 ? 'mb-1' : '',
+ ]"
>
- <button
- type="button"
- class="absolute top-1 right-1 z-10 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:opacity-100 transition-opacity text-white hover:bg-white/20"
- :title="$t('messages.message_actions')"
- @click.stop="cv.onMessageContextMenu($event, entry.items[0])"
- >
- <MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
- </button>
<div
- v-if="cv.imageGroupSortedChron(entry.items).length === 2"
- class="grid grid-cols-2 gap-0.5 bg-black/5 dark:bg-white/5"
+ class="relative rounded-2xl overflow-hidden ring-1 ring-black/10 dark:ring-white/10 shadow-md"
+ @click.stop="cv.onChatItemClick(entry.items[0])"
+ @contextmenu.prevent.stop="cv.onMessageContextMenu($event, entry.items[0], true)"
>
<button
- v-for="imgItem in cv.imageGroupSortedChron(entry.items)"
- :id="`message-${imgItem.lxmf_message.hash}`"
- :key="imgItem.lxmf_message.hash"
type="button"
- class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
- @click.stop="
- cv.openImage(cv.lxmfImageUrl(imgItem.lxmf_message.hash), cv.imageGroupGalleryUrls(entry.items))
- "
- @contextmenu.prevent.stop="cv.onMessageContextMenu($event, imgItem, true)"
+ class="absolute top-1 right-1 z-10 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:opacity-100 transition-opacity text-white hover:bg-white/20"
+ :title="$t('messages.message_actions')"
+ @click.stop="cv.onMessageContextMenu($event, entry.items[0])"
>
- <InViewAnimatedImg
- v-if="isAnimatedRasterType(imgItem.lxmf_message.fields?.image?.image_type)"
- :src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
- fit-parent
- img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
- />
- <img
- v-else
- :src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
- loading="lazy"
- decoding="async"
- class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
- alt=""
- />
+ <MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
</button>
- </div>
- <div
- v-else-if="cv.imageGroupSortedChron(entry.items).length === 3"
- class="grid grid-cols-2 gap-0.5 bg-black/5 dark:bg-white/5"
- >
- <button
- v-for="imgItem in cv.imageGroupSortedChron(entry.items).slice(0, 2)"
- :id="`message-${imgItem.lxmf_message.hash}`"
- :key="imgItem.lxmf_message.hash"
- type="button"
- class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
- @click.stop="
- cv.openImage(cv.lxmfImageUrl(imgItem.lxmf_message.hash), cv.imageGroupGalleryUrls(entry.items))
- "
- @contextmenu.prevent.stop="cv.onMessageContextMenu($event, imgItem, true)"
- >
- <InViewAnimatedImg
- v-if="isAnimatedRasterType(imgItem.lxmf_message.fields?.image?.image_type)"
- :src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
- fit-parent
- img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
- />
- <img
- v-else
- :src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
- loading="lazy"
- decoding="async"
- class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
- alt=""
- />
- </button>
- <button
- :id="`message-${cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash}`"
- type="button"
- class="relative col-span-2 aspect-2/1 max-h-52 min-h-[80px] w-full overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
- @click.stop="
- cv.openImage(
- cv.lxmfImageUrl(cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash),
- cv.imageGroupGalleryUrls(entry.items)
- )
- "
- @contextmenu.prevent.stop="
- cv.onMessageContextMenu($event, cv.imageGroupSortedChron(entry.items)[2], true)
- "
+ <div
+ v-if="cv.imageGroupSortedChron(entry.items).length === 2"
+ class="grid grid-cols-2 gap-0.5 bg-black/5 dark:bg-white/5"
>
- <InViewAnimatedImg
- v-if="
- isAnimatedRasterType(
- cv.imageGroupSortedChron(entry.items)[2].lxmf_message.fields?.image?.image_type
+ <button
+ v-for="imgItem in cv.imageGroupSortedChron(entry.items)"
+ :id="`message-${imgItem.lxmf_message.hash}`"
+ :key="imgItem.lxmf_message.hash"
+ type="button"
+ class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
+ @click.stop="
+ cv.openImage(
+ cv.lxmfImageUrl(imgItem.lxmf_message.hash),
+ cv.imageGroupGalleryUrls(entry.items)
)
"
- :src="cv.lxmfImageUrl(cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash)"
- fit-parent
- img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
- />
- <img
- v-else
- :src="cv.lxmfImageUrl(cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash)"
- loading="lazy"
- decoding="async"
- class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
- alt=""
- />
- </button>
- </div>
- <div v-else class="grid grid-cols-2 gap-0.5 bg-black/5 dark:bg-white/5">
- <button
- v-for="(cell, idx) in cv.imageGroupSortedChron(entry.items).slice(0, 4)"
- :id="`message-${cell.lxmf_message.hash}`"
- :key="cell.lxmf_message.hash"
- type="button"
- class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
- @click.stop="
- cv.openImage(cv.lxmfImageUrl(cell.lxmf_message.hash), cv.imageGroupGalleryUrls(entry.items))
- "
- @contextmenu.prevent.stop="cv.onMessageContextMenu($event, cell, true)"
+ @contextmenu.prevent.stop="cv.onMessageContextMenu($event, imgItem, true)"
+ >
+ <InViewAnimatedImg
+ v-if="isAnimatedRasterType(imgItem.lxmf_message.fields?.image?.image_type)"
+ :src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
+ fit-parent
+ img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
+ />
+ <img
+ v-else
+ :src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
+ loading="lazy"
+ decoding="async"
+ class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
+ alt=""
+ />
+ </button>
+ </div>
+ <div
+ v-else-if="cv.imageGroupSortedChron(entry.items).length === 3"
+ class="grid grid-cols-2 gap-0.5 bg-black/5 dark:bg-white/5"
>
- <InViewAnimatedImg
- v-if="isAnimatedRasterType(cell.lxmf_message.fields?.image?.image_type)"
- :src="cv.lxmfImageUrl(cell.lxmf_message.hash)"
- fit-parent
- img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
- />
- <img
- v-else
- :src="cv.lxmfImageUrl(cell.lxmf_message.hash)"
- loading="lazy"
- decoding="async"
- class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
- alt=""
- />
- <div
- v-if="idx === 3 && cv.imageGroupSortedChron(entry.items).length > 4"
- class="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/55 text-white text-3xl font-bold"
+ <button
+ v-for="imgItem in cv.imageGroupSortedChron(entry.items).slice(0, 2)"
+ :id="`message-${imgItem.lxmf_message.hash}`"
+ :key="imgItem.lxmf_message.hash"
+ type="button"
+ class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
+ @click.stop="
+ cv.openImage(
+ cv.lxmfImageUrl(imgItem.lxmf_message.hash),
+ cv.imageGroupGalleryUrls(entry.items)
+ )
+ "
+ @contextmenu.prevent.stop="cv.onMessageContextMenu($event, imgItem, true)"
>
- +{{ cv.imageGroupSortedChron(entry.items).length - 4 }}
- </div>
- </button>
+ <InViewAnimatedImg
+ v-if="isAnimatedRasterType(imgItem.lxmf_message.fields?.image?.image_type)"
+ :src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
+ fit-parent
+ img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
+ />
+ <img
+ v-else
+ :src="cv.lxmfImageUrl(imgItem.lxmf_message.hash)"
+ loading="lazy"
+ decoding="async"
+ class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
+ alt=""
+ />
+ </button>
+ <button
+ :id="`message-${cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash}`"
+ type="button"
+ class="relative col-span-2 aspect-2/1 max-h-52 min-h-[80px] w-full overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
+ @click.stop="
+ cv.openImage(
+ cv.lxmfImageUrl(cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash),
+ cv.imageGroupGalleryUrls(entry.items)
+ )
+ "
+ @contextmenu.prevent.stop="
+ cv.onMessageContextMenu($event, cv.imageGroupSortedChron(entry.items)[2], true)
+ "
+ >
+ <InViewAnimatedImg
+ v-if="
+ isAnimatedRasterType(
+ cv.imageGroupSortedChron(entry.items)[2].lxmf_message.fields?.image?.image_type
+ )
+ "
+ :src="cv.lxmfImageUrl(cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash)"
+ fit-parent
+ img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
+ />
+ <img
+ v-else
+ :src="cv.lxmfImageUrl(cv.imageGroupSortedChron(entry.items)[2].lxmf_message.hash)"
+ loading="lazy"
+ decoding="async"
+ class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
+ alt=""
+ />
+ </button>
+ </div>
+ <div v-else class="grid grid-cols-2 gap-0.5 bg-black/5 dark:bg-white/5">
+ <button
+ v-for="(cell, idx) in cv.imageGroupSortedChron(entry.items).slice(0, 4)"
+ :id="`message-${cell.lxmf_message.hash}`"
+ :key="cell.lxmf_message.hash"
+ type="button"
+ class="relative aspect-square min-h-[96px] max-h-[220px] min-w-0 overflow-hidden focus:outline-hidden focus-visible:ring-2 focus-visible:ring-white/80"
+ @click.stop="
+ cv.openImage(cv.lxmfImageUrl(cell.lxmf_message.hash), cv.imageGroupGalleryUrls(entry.items))
+ "
+ @contextmenu.prevent.stop="cv.onMessageContextMenu($event, cell, true)"
+ >
+ <InViewAnimatedImg
+ v-if="isAnimatedRasterType(cell.lxmf_message.fields?.image?.image_type)"
+ :src="cv.lxmfImageUrl(cell.lxmf_message.hash)"
+ fit-parent
+ img-class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
+ />
+ <img
+ v-else
+ :src="cv.lxmfImageUrl(cell.lxmf_message.hash)"
+ loading="lazy"
+ decoding="async"
+ class="h-full w-full object-cover object-center transition-transform hover:scale-[1.02]"
+ alt=""
+ />
+ <div
+ v-if="idx === 3 && cv.imageGroupSortedChron(entry.items).length > 4"
+ class="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/55 text-white text-3xl font-bold"
+ >
+ +{{ cv.imageGroupSortedChron(entry.items).length - 4 }}
+ </div>
+ </button>
+ </div>
+ <OutboundTransferProgressFooter
+ v-if="entry.items[0].is_outbound"
+ :lxmf-message="entry.items[0].lxmf_message"
+ :chat-item="entry.items[0]"
+ :cv="cv"
+ variant="image"
+ />
</div>
+ <MessageReactionsOverlay
+ :reactions="entry.items[0].lxmf_message.reactions"
+ :is-outbound="entry.items[0].is_outbound"
+ :chat-item="entry.items[0]"
+ :cv="cv"
+ :elevated="entry.items[0].is_outbound && cv.showOutboundTransferProgress(entry.items[0].lxmf_message)"
+ />
</div>
<div
class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md min-w-0 px-3 py-2"
@@ -301,24 +328,6 @@
</button>
</div>
</div>
- <div
- v-if="
- entry.items[0].is_outbound && cv.outboundTransferProgressPercent(entry.items[0].lxmf_message) !== null
- "
- class="flex items-center gap-2 justify-end mt-1 w-full min-w-[8rem] max-w-[min(280px,85vw)]"
- >
- <div class="flex-1 h-1.5 rounded-full bg-gray-200/90 dark:bg-zinc-700/90 overflow-hidden">
- <div
- class="h-full rounded-full bg-blue-500 dark:bg-blue-400 transition-all duration-300"
- :style="{
- width: cv.outboundTransferProgressPercent(entry.items[0].lxmf_message) + '%',
- }"
- ></div>
- </div>
- <span class="text-[11px] font-semibold tabular-nums text-gray-500 dark:text-zinc-400 shrink-0">
- {{ cv.outboundSendingProgressLabel(entry.items[0].lxmf_message) }}
- </span>
- </div>
<div
v-if="cv.expandedMessageInfo === entry.items[0].lxmf_message.hash"
class="mt-2 px-1 text-xs text-gray-500 dark:text-zinc-400 space-y-0.5"
@@ -332,40 +341,6 @@
{{ line }}
</div>
</div>
- <div
- class="flex w-fit max-w-full flex-wrap items-center gap-0.5 px-0.5"
- :class="[
- entry.items[0].is_outbound ? 'self-end justify-end' : 'self-start justify-start',
- (entry.items[0].lxmf_message.reactions?.length ?? 0) > 0 ? 'mt-0.5' : 'mt-0',
- ]"
- >
- <span
- v-for="(r, ridx) in entry.items[0].lxmf_message.reactions"
- :key="r.reactionHash || ridx"
- class="inline-flex min-h-4.5 min-w-4.5 cursor-default select-none items-center justify-center rounded-full border border-gray-200/90 bg-white px-1 py-0 text-sm leading-none shadow-xs dark:border-zinc-600/90 dark:bg-zinc-900"
- :style="{
- order: entry.items[0].is_outbound ? ridx + 2 : ridx + 1,
- }"
- :title="cv.reactionReactorLabel(r.sender)"
- >{{ r.emoji }}</span
- >
- <button
- type="button"
- class="inline-flex items-center justify-center rounded-full border border-dashed border-gray-300 bg-transparent text-xs leading-none text-gray-400 hover:border-gray-400 hover:text-gray-600 hover:bg-gray-50 dark:border-zinc-600 dark:text-zinc-500 dark:hover:border-zinc-500 dark:hover:text-zinc-300 dark:hover:bg-zinc-800 transition-colors opacity-0 group-hover:opacity-100"
- :class="
- (entry.items[0].lxmf_message.reactions?.length ?? 0) > 0
- ? 'min-h-4.5 min-w-4.5 px-1 py-0'
- : 'h-4 w-4 min-h-0 p-0'
- "
- :style="{
- order: entry.items[0].is_outbound ? 1 : (entry.items[0].lxmf_message.reactions?.length ?? 0) + 1,
- }"
- :title="$t('messages.react')"
- @click.stop="cv.openReactionPicker(entry.items[0])"
- >
- <MaterialDesignIcon icon-name="emoticon-plus-outline" class="size-3" />
- </button>
- </div>
</div>
<div
v-else-if="entry.type === 'dateDivider'"
@@ -384,9 +359,9 @@
<div
v-for="chatItem in [entry.chatItem]"
v-else
- :id="`message-${chatItem.lxmf_message.hash}`"
+ :id="cv.isImageOnlyMessage(chatItem) ? `message-${chatItem.lxmf_message.hash}` : undefined"
:key="chatItem.lxmf_message.hash"
- class="flex flex-col max-w-[85%] sm:max-w-[75%] md:max-lg:max-w-[70%] lg:max-w-[65%] mb-2 group min-w-0"
+ class="flex flex-col max-w-[85%] sm:max-w-[75%] md:max-lg:max-w-[70%] lg:max-w-[65%] mb-2 group min-w-0 shrink-0 [overflow-anchor:none]"
:class="{
'ml-auto items-end': chatItem.is_outbound,
'mr-auto items-start': !chatItem.is_outbound,
@@ -396,43 +371,65 @@
<!-- standalone image (outside bubble) -->
<div
v-if="chatItem.lxmf_message.fields?.image"
- class="relative group w-full max-w-[min(280px,85vw)] rounded-2xl overflow-hidden ring-1 ring-black/10 dark:ring-white/10 shadow-md mb-1.5"
- :class="chatItem.is_outbound ? 'ml-auto' : 'mr-auto'"
- @contextmenu.prevent.stop="cv.onMessageContextMenu($event, chatItem, true)"
+ class="relative w-full max-w-[min(280px,85vw)] mb-1.5"
+ :class="[
+ chatItem.is_outbound ? 'ml-auto' : 'mr-auto',
+ cv.isImageOnlyMessage(chatItem) && (chatItem.lxmf_message.reactions?.length ?? 0) > 0 ? 'mb-1' : '',
+ ]"
>
- <template
- v-if="['tgs', 'webm'].includes((chatItem.lxmf_message.fields.image.image_type || '').toLowerCase())"
- >
- <StickerView
- :src="cv.pendingOutboundImageSrc(chatItem)"
- :image-type="(chatItem.lxmf_message.fields.image.image_type || '').toLowerCase()"
- class="max-h-[min(320px,55vh)] w-full bg-black/5 dark:bg-white/5"
- />
- </template>
- <template v-else>
- <InViewAnimatedImg
- v-if="isAnimatedRasterType(chatItem.lxmf_message.fields?.image?.image_type)"
- :src="cv.pendingOutboundImageSrc(chatItem)"
- img-class="max-h-[min(320px,55vh)] w-full cursor-pointer object-contain object-center bg-black/5 dark:bg-white/5 transition-transform hover:scale-[1.01]"
- @click.stop="cv.openImage(cv.pendingOutboundImageSrc(chatItem))"
- />
- <img
- v-else
- :src="cv.pendingOutboundImageSrc(chatItem)"
- loading="lazy"
- decoding="async"
- class="max-h-[min(320px,55vh)] w-full cursor-pointer object-contain object-center bg-black/5 dark:bg-white/5 transition-transform hover:scale-[1.01]"
- alt=""
- @click.stop="cv.openImage(cv.pendingOutboundImageSrc(chatItem))"
- />
- </template>
<div
- class="pointer-events-none absolute bottom-2 left-2 rounded-lg bg-black/60 px-2.5 py-1 text-xs text-white opacity-0 backdrop-blur-xs transition-opacity group-hover:opacity-100 sm:opacity-100"
+ class="relative rounded-2xl overflow-hidden ring-1 ring-black/10 dark:ring-white/10 shadow-md"
+ @contextmenu.prevent.stop="cv.onMessageContextMenu($event, chatItem, true)"
>
- <span>{{ (chatItem.lxmf_message.fields.image.image_type ?? "image").toUpperCase() }}</span>
- <span class="mx-1">·</span>
- <span>{{ cv.formatAttachmentSize(chatItem.lxmf_message.fields.image, "image") }}</span>
+ <template
+ v-if="['tgs', 'webm'].includes((chatItem.lxmf_message.fields.image.image_type || '').toLowerCase())"
+ >
+ <StickerView
+ :src="cv.pendingOutboundImageSrc(chatItem)"
+ :image-type="(chatItem.lxmf_message.fields.image.image_type || '').toLowerCase()"
+ class="max-h-[min(320px,55vh)] w-full bg-black/5 dark:bg-white/5"
+ />
+ </template>
+ <template v-else>
+ <InViewAnimatedImg
+ v-if="isAnimatedRasterType(chatItem.lxmf_message.fields?.image?.image_type)"
+ :src="cv.pendingOutboundImageSrc(chatItem)"
+ img-class="max-h-[min(320px,55vh)] w-full cursor-pointer object-contain object-center bg-black/5 dark:bg-white/5 transition-transform hover:scale-[1.01]"
+ @click.stop="cv.openImage(cv.pendingOutboundImageSrc(chatItem))"
+ />
+ <img
+ v-else
+ :src="cv.pendingOutboundImageSrc(chatItem)"
+ loading="lazy"
+ decoding="async"
+ class="max-h-[min(320px,55vh)] w-full cursor-pointer object-contain object-center bg-black/5 dark:bg-white/5 transition-transform hover:scale-[1.01]"
+ alt=""
+ @click.stop="cv.openImage(cv.pendingOutboundImageSrc(chatItem))"
+ />
+ </template>
+ <div
+ class="pointer-events-none absolute bottom-2 left-2 rounded-lg bg-black/60 px-2.5 py-1 text-xs text-white opacity-0 backdrop-blur-xs transition-opacity group-hover:opacity-100 sm:opacity-100"
+ >
+ <span>{{ (chatItem.lxmf_message.fields.image.image_type ?? "image").toUpperCase() }}</span>
+ <span class="mx-1">·</span>
+ <span>{{ cv.formatAttachmentSize(chatItem.lxmf_message.fields.image, "image") }}</span>
+ </div>
+ <OutboundTransferProgressFooter
+ v-if="chatItem.is_outbound"
+ :lxmf-message="chatItem.lxmf_message"
+ :chat-item="chatItem"
+ :cv="cv"
+ variant="image"
+ />
</div>
+ <MessageReactionsOverlay
+ v-if="cv.isImageOnlyMessage(chatItem)"
+ :reactions="chatItem.lxmf_message.reactions"
+ :is-outbound="chatItem.is_outbound"
+ :chat-item="chatItem"
+ :cv="cv"
+ :elevated="chatItem.is_outbound && cv.showOutboundTransferProgress(chatItem.lxmf_message)"
+ />
</div>
<!-- image-only: inline timestamp overlay (no bubble) -->
<div
@@ -465,770 +462,757 @@
<!-- message content -->
<div
- v-if="!cv.isImageOnlyMessage(chatItem)"
- class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md min-w-0"
- :class="[
- ['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
- ? 'shadow-xs'
- : chatItem.lxmf_message.is_spam
- ? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 border border-yellow-300 dark:border-yellow-700 shadow-xs'
- : cv.isOutboundWaitingBubble(chatItem)
- ? 'shadow-xs'
- : chatItem.is_outbound
- ? cv.outboundBubbleSurfaceClass(chatItem)
- : 'bg-white dark:bg-zinc-900 text-gray-900 dark:text-zinc-100 border border-gray-200/60 dark:border-zinc-800/60 shadow-xs',
- ]"
- :style="cv.bubbleStyles(chatItem)"
- @click="cv.onChatItemClick(chatItem)"
- @contextmenu.prevent.stop="cv.onMessageContextMenu($event, chatItem, true)"
+ v-if="cv.hasMessageBubble(chatItem)"
+ :id="`message-${chatItem.lxmf_message.hash}`"
+ data-message-bubble
+ class="relative min-w-0 w-fit max-w-full shrink-0 [overflow-anchor:none]"
+ :class="(chatItem.lxmf_message.reactions?.length ?? 0) > 0 ? 'mb-1' : ''"
>
- <button
- type="button"
- class="absolute top-1 right-1 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:opacity-100 transition-opacity"
- :class="[cv.outboundMessageMenuButtonClass(chatItem), cv.outboundMessageMenuButtonHoverClass(chatItem)]"
- :title="$t('messages.message_actions')"
- @click.stop="cv.onMessageContextMenu($event, chatItem, false)"
+ <div
+ class="relative rounded-2xl overflow-hidden transition-all duration-200 hover:shadow-md min-w-0 w-fit max-w-full"
+ :class="[
+ ['cancelled', 'failed'].includes(chatItem.lxmf_message.state)
+ ? 'shadow-xs'
+ : chatItem.lxmf_message.is_spam
+ ? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 border border-yellow-300 dark:border-yellow-700 shadow-xs'
+ : cv.isOutboundWaitingBubble(chatItem)
+ ? 'shadow-xs'
+ : chatItem.is_outbound
+ ? cv.outboundBubbleSurfaceClass(chatItem)
+ : 'bg-white dark:bg-zinc-900 text-gray-900 dark:text-zinc-100 border border-gray-200/60 dark:border-zinc-800/60 shadow-xs',
+ ]"
+ :style="cv.bubbleStyles(chatItem)"
+ @click="cv.onChatItemClick(chatItem)"
+ @contextmenu.prevent.stop="cv.onMessageContextMenu($event, chatItem, true)"
>
- <MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
- </button>
- <div class="w-full space-y-1 px-4 py-2.5 min-w-0">
- <!-- reply snippet -->
- <div
- v-if="chatItem.lxmf_message.reply_to_hash"
- class="mb-2 p-2 rounded-lg bg-black/5 dark:bg-white/5 border-l-2 border-blue-500/50 cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
- @click.stop="cv.scrollToMessage(chatItem.lxmf_message.reply_to_hash)"
- >
- <div
- class="flex items-center gap-1 text-[10px] font-bold uppercase tracking-tight mb-0.5"
- :class="cv.outboundReplySnippetTitleClass(chatItem)"
- >
- <MaterialDesignIcon icon-name="reply" class="size-3" />
- {{ $t("messages.replying_to") }}
- </div>
- <div class="text-xs opacity-70 break-words italic">
- {{
- chatItem.lxmf_message.fields?.reply_quoted_content ||
- cv.getRepliedMessage(chatItem.lxmf_message.reply_to_hash)?.content ||
- (chatItem.lxmf_message.reply_to_hash
- ? `Message <${chatItem.lxmf_message.reply_to_hash.substring(0, 8)}...>`
- : "(Message not found)")
- }}
- </div>
- </div>
-
- <!-- spam badge -->
- <div
- v-if="chatItem.lxmf_message.is_spam"
- class="flex items-center gap-1.5 text-xs font-medium mb-1"
- :class="
- chatItem.is_outbound
- ? cv.isThemeOutboundBubble(chatItem)
- ? 'text-orange-800 dark:text-orange-300'
- : 'text-orange-200'
- : 'text-orange-700 dark:text-orange-300'
- "
- >
- <MaterialDesignIcon icon-name="alert-decagram" class="size-4" />
- <span>Marked as Spam</span>
- </div>
-
- <!-- content -->
- <div
- v-if="
- chatItem.lxmf_message.content &&
- !cv.getParsedItems(chatItem)?.isOnlyPaperMessage &&
- !cv.getParsedItems(chatItem)?.isOnlyMapLink &&
- !cv.shouldHideAutoImageCaption(chatItem) &&
- cv.isMessageBodyTooLargeForDisplay(chatItem)
- "
- class="rounded-lg border border-amber-200/90 dark:border-amber-800/50 bg-amber-50/90 dark:bg-amber-950/25 px-3 py-2.5 space-y-2 min-w-0"
- >
- <div class="flex items-start gap-2">
- <MaterialDesignIcon
- icon-name="text-box-outline"
- class="size-5 shrink-0 text-amber-800 dark:text-amber-300/90 mt-0.5"
- />
- <p class="text-xs text-amber-950 dark:text-amber-100/90 leading-relaxed min-w-0">
- {{
- $t("messages.oversized_body_notice", {
- count: cv.messageBodyCharCount(chatItem),
- })
- }}
- </p>
- </div>
- <button
- type="button"
- class="inline-flex items-center gap-2 rounded-lg bg-amber-700 hover:bg-amber-800 dark:bg-amber-700 dark:hover:bg-amber-600 px-3 py-2 text-xs font-semibold text-white transition-colors"
- @click.stop="cv.copyOversizedMessageBody(chatItem)"
- >
- <MaterialDesignIcon icon-name="content-copy" class="size-4 shrink-0" />
- {{ $t("messages.oversized_body_copy") }}
- </button>
- </div>
- <!-- eslint-disable vue/no-v-html -->
- <div
- v-else-if="
- chatItem.lxmf_message.content &&
- !cv.getParsedItems(chatItem)?.isOnlyPaperMessage &&
- !cv.getParsedItems(chatItem)?.isOnlyMapLink &&
- !cv.shouldHideAutoImageCaption(chatItem)
- "
- class="min-w-0"
+ <button
+ type="button"
+ class="absolute top-1 right-1 p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:opacity-100 transition-opacity"
+ :class="[
+ cv.outboundMessageMenuButtonClass(chatItem),
+ cv.outboundMessageMenuButtonHoverClass(chatItem),
+ ]"
+ :title="$t('messages.message_actions')"
+ @click.stop="cv.onMessageContextMenu($event, chatItem, false)"
>
+ <MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
+ </button>
+ <div class="w-full space-y-1 px-4 py-2.5 min-w-0">
+ <!-- reply snippet -->
<div
- v-if="cv.bubbleViewModel(chatItem).kind === 'loading'"
- class="text-sm text-indigo-600/90 dark:text-indigo-300 py-0.5"
+ v-if="chatItem.lxmf_message.reply_to_hash"
+ class="mb-2 p-2 rounded-lg bg-black/5 dark:bg-white/5 border-l-2 border-blue-500/50 cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
+ @click.stop="cv.scrollToMessage(chatItem.lxmf_message.reply_to_hash)"
>
- {{ $t("messages.translating_message") }}
- </div>
- <div v-else>
- <div
- class="leading-relaxed wrap-break-word [word-break:break-word] min-w-0 markdown-content"
- :class="{
- 'markdown-content--outbound-theme':
- chatItem.is_outbound && cv.isThemeOutboundBubble(chatItem),
- 'markdown-content--outbound-solid':
- chatItem.is_outbound && !cv.isThemeOutboundBubble(chatItem),
- 'markdown-content--inbound': !chatItem.is_outbound,
- 'markdown-content--single-emoji': cv.bubbleViewModel(chatItem).singleEmoji,
- }"
- :style="{
- 'font-family': 'inherit',
- 'font-size': cv.bubbleMessageBodyFontSizePx(cv.bubbleViewModel(chatItem)) + 'px',
- }"
- @click="cv.handleMessageClick"
- v-html="cv.renderMarkdown(cv.bubbleViewModel(chatItem).textForRender)"
- ></div>
<div
- v-if="cv.bubbleViewModel(chatItem).showFooter"
- class="mt-1.5 pt-1.5 border-t border-black/5 dark:border-white/5 text-xs text-gray-500 dark:text-zinc-500"
+ class="flex items-center gap-1 text-[10px] font-bold uppercase tracking-tight mb-0.5"
+ :class="cv.outboundReplySnippetTitleClass(chatItem)"
>
- <div v-if="cv.bubbleViewModel(chatItem).showOriginalLink" class="wrap-break-word">
- <span>{{
- $t("messages.translated_from_to", {
- source: String(cv.bubbleViewModel(chatItem).fromCode || "").toUpperCase(),
- target: String(cv.bubbleViewModel(chatItem).toCode || "").toUpperCase(),
- })
- }}</span>
- <button
- type="button"
- class="ml-1.5 text-indigo-600 dark:text-indigo-400 hover:underline"
- @click.stop="
- cv.setBubbleMessageShowOriginal(cv.bubbleViewModel(chatItem).messageHash, true)
- "
- >
- {{ $t("messages.show_original") }}
- </button>
- </div>
- <div v-else-if="cv.bubbleViewModel(chatItem).showTranslationLink">
- <button
- type="button"
- class="text-indigo-600 dark:text-indigo-400 hover:underline"
- @click.stop="
- cv.setBubbleMessageShowOriginal(cv.bubbleViewModel(chatItem).messageHash, false)
- "
- >
- {{ $t("messages.show_translation") }}
- </button>
- </div>
+ <MaterialDesignIcon icon-name="reply" class="size-3" />
+ {{ $t("messages.replying_to") }}
+ </div>
+ <div class="text-xs opacity-70 break-words italic">
+ {{
+ chatItem.lxmf_message.fields?.reply_quoted_content ||
+ cv.getRepliedMessage(chatItem.lxmf_message.reply_to_hash)?.content ||
+ (chatItem.lxmf_message.reply_to_hash
+ ? `Message <${chatItem.lxmf_message.reply_to_hash.substring(0, 8)}...>`
+ : "(Message not found)")
+ }}
</div>
</div>
- </div>
- <!-- eslint-enable vue/no-v-html -->
-
- <!-- telemetry placeholder for empty content messages -->
- <div
- v-if="!chatItem.lxmf_message.content && chatItem.lxmf_message.fields?.telemetry"
- class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
- >
- <MaterialDesignIcon icon-name="satellite-variant" class="size-4 opacity-60" />
- <span class="text-[10px] font-bold uppercase tracking-wider opacity-60">
- {{ chatItem.is_outbound ? "Telemetry update sent" : "Telemetry update received" }}
- </span>
- </div>
-
- <div
- v-if="!chatItem.lxmf_message.content && chatItem.lxmf_message.fields?.telemetry_stream"
- class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
- >
- <MaterialDesignIcon icon-name="database-sync" class="size-4 opacity-60" />
- <span class="text-[10px] font-bold uppercase tracking-wider opacity-60"
- >Telemetry stream received ({{
- chatItem.lxmf_message.fields.telemetry_stream.length
- }}
- entries)</span
- >
- </div>
-
- <div
- v-if="
- !chatItem.lxmf_message.content &&
- chatItem.lxmf_message.fields?.commands?.some((c) => c['0x01'] || c['1'] || c['0x1'])
- "
- class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
- >
- <MaterialDesignIcon icon-name="crosshairs-question" class="size-4 opacity-60" />
- <span class="text-[10px] font-bold uppercase tracking-wider opacity-60">
- {{ chatItem.is_outbound ? "Location Request Sent" : "Location Request Received" }}
- </span>
- </div>
- <!-- parsed items (contacts / paper messages) -->
- <div v-if="cv.getParsedItems(chatItem)" class="mt-2 space-y-2">
- <!-- contact -->
+ <!-- spam badge -->
<div
- v-if="cv.getParsedItems(chatItem).contact"
- class="flex flex-col gap-2 p-3 rounded-xl border"
+ v-if="chatItem.lxmf_message.is_spam"
+ class="flex items-center gap-1.5 text-xs font-medium mb-1"
:class="
chatItem.is_outbound
? cv.isThemeOutboundBubble(chatItem)
- ? 'bg-white/10 border-white/20'
- : 'bg-black/5 border-black/10'
- : 'bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-800/30'
+ ? 'text-orange-800 dark:text-orange-300'
+ : 'text-orange-200'
+ : 'text-orange-700 dark:text-orange-300'
"
>
- <div
- class="flex items-center gap-2"
- :class="
- chatItem.is_outbound
- ? cv.isThemeOutboundBubble(chatItem)
- ? 'text-white'
- : 'text-gray-800 dark:text-zinc-200'
- : 'text-blue-700 dark:text-blue-300'
- "
- >
- <MaterialDesignIcon icon-name="account-plus-outline" class="size-5" />
- <span class="text-sm font-bold">Contact Shared</span>
- </div>
- <div class="flex items-center gap-3">
- <div class="flex-1 min-w-0">
- <div
- class="text-sm font-bold truncate"
- :class="
- chatItem.is_outbound
- ? cv.isThemeOutboundBubble(chatItem)
- ? 'text-white'
- : 'text-gray-900 dark:text-white'
- : 'text-gray-900 dark:text-white'
- "
- >
- {{ cv.getParsedItems(chatItem).contact.name }}
- </div>
- <div
- class="text-[10px] font-mono truncate"
- :class="
- chatItem.is_outbound
- ? cv.isThemeOutboundBubble(chatItem)
- ? 'text-white/70'
- : 'text-gray-500 dark:text-zinc-400'
- : 'text-gray-500 dark:text-zinc-400'
- "
- >
- {{ cv.getParsedItems(chatItem).contact.hash }}
- </div>
- <div
- v-if="cv.getParsedItems(chatItem).contact.lxmf_address"
- class="text-[9px] font-mono truncate"
- :class="
- chatItem.is_outbound
- ? cv.isThemeOutboundBubble(chatItem)
- ? 'text-white/60'
- : 'text-gray-400 dark:text-zinc-500'
- : 'text-gray-400 dark:text-zinc-500'
- "
- >
- LXMF: {{ cv.getParsedItems(chatItem).contact.lxmf_address }}
- </div>
- <div
- v-if="cv.getParsedItems(chatItem).contact.lxst_address"
- class="text-[9px] font-mono truncate"
- :class="
- chatItem.is_outbound
- ? cv.isThemeOutboundBubble(chatItem)
- ? 'text-white/60'
- : 'text-gray-400 dark:text-zinc-500'
- : 'text-gray-400 dark:text-zinc-500'
- "
- >
- LXST: {{ cv.getParsedItems(chatItem).contact.lxst_address }}
- </div>
- </div>
- </div>
- <button
- v-if="!chatItem.is_outbound"
- type="button"
- class="w-full py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-xs font-bold transition-colors shadow-xs"
- @click="
- cv.addContact(
- cv.getParsedItems(chatItem).contact.name,
- cv.getParsedItems(chatItem).contact.hash,
- cv.getParsedItems(chatItem).contact.lxmf_address,
- cv.getParsedItems(chatItem).contact.lxst_address
- )
- "
- >
- Add to Contacts
- </button>
+ <MaterialDesignIcon icon-name="alert-decagram" class="size-4" />
+ <span>Marked as Spam</span>
</div>
- <!-- paper message auto-conversion -->
+ <!-- content -->
<div
- v-if="cv.getParsedItems(chatItem).paperMessage"
- class="flex flex-col gap-2 p-3 rounded-xl border"
- :class="
- chatItem.is_outbound
- ? cv.isThemeOutboundBubble(chatItem)
- ? 'bg-white/10 border-white/20'
- : 'bg-black/5 border-black/10'
- : 'bg-emerald-50 dark:bg-black/60 border-emerald-100 dark:border-zinc-700/50'
+ v-if="
+ chatItem.lxmf_message.content &&
+ !cv.getParsedItems(chatItem)?.isOnlyPaperMessage &&
+ !cv.getParsedItems(chatItem)?.isOnlyMapLink &&
+ !cv.shouldHideAutoImageCaption(chatItem) &&
+ cv.isMessageBodyTooLargeForDisplay(chatItem)
"
+ class="rounded-lg border border-amber-200/90 dark:border-amber-800/50 bg-amber-50/90 dark:bg-amber-950/25 px-3 py-2.5 space-y-2 min-w-0"
>
- <div
- class="flex items-center gap-2"
- :class="
- chatItem.is_outbound
- ? cv.isThemeOutboundBubble(chatItem)
- ? 'text-white'
- : 'text-gray-800 dark:text-zinc-200'
- : 'text-emerald-700 dark:text-emerald-400'
- "
- >
- <MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
- <span class="text-sm font-bold">Paper Message detected</span>
+ <div class="flex items-start gap-2">
+ <MaterialDesignIcon
+ icon-name="text-box-outline"
+ class="size-5 shrink-0 text-amber-800 dark:text-amber-300/90 mt-0.5"
+ />
+ <p class="text-xs text-amber-950 dark:text-amber-100/90 leading-relaxed min-w-0">
+ {{
+ $t("messages.oversized_body_notice", {
+ count: cv.messageBodyCharCount(chatItem),
+ })
+ }}
+ </p>
</div>
- <p
- class="text-xs leading-relaxed"
- :class="
- chatItem.is_outbound
- ? cv.isThemeOutboundBubble(chatItem)
- ? 'text-white/80'
- : 'text-gray-600 dark:text-zinc-400'
- : 'text-emerald-600/80 dark:text-zinc-400'
- "
- >
- This message contains a signed LXMF URI that can be ingested into your conversations.
- </p>
<button
- v-if="!chatItem.is_outbound"
type="button"
- class="w-full py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold transition-colors shadow-xs"
- @click="cv.ingestPaperMessage(cv.getParsedItems(chatItem).paperMessage)"
+ class="inline-flex items-center gap-2 rounded-lg bg-amber-700 hover:bg-amber-800 dark:bg-amber-700 dark:hover:bg-amber-600 px-3 py-2 text-xs font-semibold text-white transition-colors"
+ @click.stop="cv.copyOversizedMessageBody(chatItem)"
>
- Ingest Message
+ <MaterialDesignIcon icon-name="content-copy" class="size-4 shrink-0" />
+ {{ $t("messages.oversized_body_copy") }}
</button>
</div>
-
+ <!-- eslint-disable vue/no-v-html -->
<div
- v-if="cv.getParsedItems(chatItem).mapLink"
- class="flex flex-col gap-2 p-3 rounded-xl bg-sky-50 dark:bg-sky-950/30 border border-sky-200 dark:border-sky-800/50"
+ v-else-if="
+ chatItem.lxmf_message.content &&
+ !cv.getParsedItems(chatItem)?.isOnlyPaperMessage &&
+ !cv.getParsedItems(chatItem)?.isOnlyMapLink &&
+ !cv.shouldHideAutoImageCaption(chatItem)
+ "
+ class="min-w-0"
>
- <div class="flex items-center gap-2 text-sky-800 dark:text-sky-300">
- <MaterialDesignIcon icon-name="map-marker-radius" class="size-5" />
- <span class="text-sm font-bold">{{
- cv.getParsedItems(chatItem).mapLink.kind === "ping"
- ? $t("messages.map_link_ping_title")
- : $t("messages.map_link_share_title")
- }}</span>
+ <div
+ v-if="cv.bubbleViewModel(chatItem).kind === 'loading'"
+ class="text-sm text-indigo-600/90 dark:text-indigo-300 py-0.5"
+ >
+ {{ $t("messages.translating_message") }}
</div>
- <div class="text-[10px] font-mono text-sky-900/80 dark:text-sky-200/90 break-all">
- {{ cv.getParsedItems(chatItem).mapLink.parsed.lat.toFixed(5) }},
- {{ cv.getParsedItems(chatItem).mapLink.parsed.lon.toFixed(5) }}
- (z{{ cv.getParsedItems(chatItem).mapLink.parsed.zoom }})
+ <div v-else>
+ <div
+ class="leading-relaxed wrap-break-word [word-break:break-word] min-w-0 markdown-content"
+ :class="{
+ 'markdown-content--outbound-theme':
+ chatItem.is_outbound && cv.isThemeOutboundBubble(chatItem),
+ 'markdown-content--outbound-solid':
+ chatItem.is_outbound && !cv.isThemeOutboundBubble(chatItem),
+ 'markdown-content--inbound': !chatItem.is_outbound,
+ 'markdown-content--single-emoji': cv.bubbleViewModel(chatItem).singleEmoji,
+ }"
+ :style="{
+ 'font-family': 'inherit',
+ 'font-size': cv.bubbleMessageBodyFontSizePx(cv.bubbleViewModel(chatItem)) + 'px',
+ }"
+ @click="cv.handleMessageClick"
+ v-html="cv.renderMarkdown(cv.bubbleViewModel(chatItem).textForRender)"
+ ></div>
+ <div
+ v-if="cv.bubbleViewModel(chatItem).showFooter"
+ class="mt-1.5 pt-1.5 border-t border-black/5 dark:border-white/5 text-xs text-gray-500 dark:text-zinc-500"
+ >
+ <div v-if="cv.bubbleViewModel(chatItem).showOriginalLink" class="wrap-break-word">
+ <span>{{
+ $t("messages.translated_from_to", {
+ source: String(cv.bubbleViewModel(chatItem).fromCode || "").toUpperCase(),
+ target: String(cv.bubbleViewModel(chatItem).toCode || "").toUpperCase(),
+ })
+ }}</span>
+ <button
+ type="button"
+ class="ml-1.5 text-indigo-600 dark:text-indigo-400 hover:underline"
+ @click.stop="
+ cv.setBubbleMessageShowOriginal(
+ cv.bubbleViewModel(chatItem).messageHash,
+ true
+ )
+ "
+ >
+ {{ $t("messages.show_original") }}
+ </button>
+ </div>
+ <div v-else-if="cv.bubbleViewModel(chatItem).showTranslationLink">
+ <button
+ type="button"
+ class="text-indigo-600 dark:text-indigo-400 hover:underline"
+ @click.stop="
+ cv.setBubbleMessageShowOriginal(
+ cv.bubbleViewModel(chatItem).messageHash,
+ false
+ )
+ "
+ >
+ {{ $t("messages.show_translation") }}
+ </button>
+ </div>
+ </div>
</div>
- <button
- type="button"
- class="w-full py-2 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-colors shadow-xs"
- @click="cv.openMapShareFromParsed(cv.getParsedItems(chatItem).mapLink.parsed)"
- >
- {{ $t("messages.map_link_open") }}
- </button>
- <button
- type="button"
- class="w-full py-2 bg-white dark:bg-zinc-900 border border-sky-200 dark:border-sky-800 text-sky-800 dark:text-sky-200 rounded-lg text-xs font-bold"
- @click="cv.copyMapShareUri(cv.getParsedItems(chatItem).mapLink.uri)"
- >
- {{ $t("messages.map_link_copy_uri") }}
- </button>
</div>
- </div>
-
- <!-- audio field -->
- <div v-if="chatItem.lxmf_message.fields?.audio" class="pb-1">
- <!-- audio is loaded -->
- <AudioWaveformPlayer
- v-if="cv.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]"
- :src="cv.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]"
- :is-outbound="chatItem.is_outbound"
- />
+ <!-- eslint-enable vue/no-v-html -->
- <!-- audio is not yet loaded -->
+ <!-- telemetry placeholder for empty content messages -->
<div
- v-else
- class="flex items-center justify-center p-2 rounded-xl bg-gray-50/50 dark:bg-zinc-800/50 border border-gray-100 dark:border-zinc-800 min-h-[54px]"
+ v-if="!chatItem.lxmf_message.content && chatItem.lxmf_message.fields?.telemetry"
+ class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
>
- <div class="flex items-center gap-2">
- <div
- class="size-4 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
- ></div>
- <span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">{{
- $t("messages.downloading")
- }}</span>
- </div>
+ <MaterialDesignIcon icon-name="satellite-variant" class="size-4 opacity-60" />
+ <span class="text-[10px] font-bold uppercase tracking-wider opacity-60">
+ {{ chatItem.is_outbound ? "Telemetry update sent" : "Telemetry update received" }}
+ </span>
</div>
<div
- class="text-[10px] mt-1 text-right opacity-60"
- :class="cv.outboundAttachmentCaptionClass(chatItem)"
+ v-if="!chatItem.lxmf_message.content && chatItem.lxmf_message.fields?.telemetry_stream"
+ class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
>
- Voice Note •
- {{ cv.formatAttachmentSize(chatItem.lxmf_message.fields.audio, "audio") }}
+ <MaterialDesignIcon icon-name="database-sync" class="size-4 opacity-60" />
+ <span class="text-[10px] font-bold uppercase tracking-wider opacity-60"
+ >Telemetry stream received ({{
+ chatItem.lxmf_message.fields.telemetry_stream.length
+ }}
+ entries)</span
+ >
</div>
- </div>
- <!-- file attachment fields -->
- <div v-if="chatItem.lxmf_message.fields?.file_attachments" class="space-y-2 mt-1">
- <a
- v-for="(file_attachment, index) of chatItem.lxmf_message.fields?.file_attachments ?? []"
- :key="file_attachment.file_name"
- target="_blank"
- :download="file_attachment.file_name"
- :href="`/api/v1/lxmf-messages/attachment/${chatItem.lxmf_message.hash}/file?file_index=${index}`"
- class="flex items-center gap-3 border rounded-lg px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
- :class="
- chatItem.is_outbound
- ? cv.outboundEmbeddedCardClass(chatItem)
- : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300 border-gray-200/60 dark:border-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-800'
+ <div
+ v-if="
+ !chatItem.lxmf_message.content &&
+ chatItem.lxmf_message.fields?.commands?.some((c) => c['0x01'] || c['1'] || c['0x1'])
"
- @click.stop
+ class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
>
- <div class="my-auto">
- <MaterialDesignIcon icon-name="paperclip" class="size-5" />
- </div>
- <div class="flex-1 min-w-0">
- <div class="truncate text-xs font-bold">
- {{ file_attachment.file_name }}
- </div>
+ <MaterialDesignIcon icon-name="crosshairs-question" class="size-4 opacity-60" />
+ <span class="text-[10px] font-bold uppercase tracking-wider opacity-60">
+ {{ chatItem.is_outbound ? "Location Request Sent" : "Location Request Received" }}
+ </span>
+ </div>
+
+ <!-- parsed items (contacts / paper messages) -->
+ <div v-if="cv.getParsedItems(chatItem)" class="mt-2 space-y-2">
+ <!-- contact -->
+ <div
+ v-if="cv.getParsedItems(chatItem).contact"
+ class="flex flex-col gap-2 p-3 rounded-xl border"
+ :class="
+ chatItem.is_outbound
+ ? cv.isThemeOutboundBubble(chatItem)
+ ? 'bg-white/10 border-white/20'
+ : 'bg-black/5 border-black/10'
+ : 'bg-blue-50 dark:bg-blue-900/20 border-blue-100 dark:border-blue-800/30'
+ "
+ >
<div
- class="text-[10px] font-normal"
+ class="flex items-center gap-2"
:class="
chatItem.is_outbound
- ? cv.outboundEmbeddedSecondaryTextClass(chatItem)
- : 'text-gray-500 dark:text-zinc-400'
+ ? cv.isThemeOutboundBubble(chatItem)
+ ? 'text-white'
+ : 'text-gray-800 dark:text-zinc-200'
+ : 'text-blue-700 dark:text-blue-300'
"
>
- {{ cv.formatAttachmentSize(file_attachment, "file") }}
+ <MaterialDesignIcon icon-name="account-plus-outline" class="size-5" />
+ <span class="text-sm font-bold">Contact Shared</span>
</div>
+ <div class="flex items-center gap-3">
+ <div class="flex-1 min-w-0">
+ <div
+ class="text-sm font-bold truncate"
+ :class="
+ chatItem.is_outbound
+ ? cv.isThemeOutboundBubble(chatItem)
+ ? 'text-white'
+ : 'text-gray-900 dark:text-white'
+ : 'text-gray-900 dark:text-white'
+ "
+ >
+ {{ cv.getParsedItems(chatItem).contact.name }}
+ </div>
+ <div
+ class="text-[10px] font-mono truncate"
+ :class="
+ chatItem.is_outbound
+ ? cv.isThemeOutboundBubble(chatItem)
+ ? 'text-white/70'
+ : 'text-gray-500 dark:text-zinc-400'
+ : 'text-gray-500 dark:text-zinc-400'
+ "
+ >
+ {{ cv.getParsedItems(chatItem).contact.hash }}
+ </div>
+ <div
+ v-if="cv.getParsedItems(chatItem).contact.lxmf_address"
+ class="text-[9px] font-mono truncate"
+ :class="
+ chatItem.is_outbound
+ ? cv.isThemeOutboundBubble(chatItem)
+ ? 'text-white/60'
+ : 'text-gray-400 dark:text-zinc-500'
+ : 'text-gray-400 dark:text-zinc-500'
+ "
+ >
+ LXMF: {{ cv.getParsedItems(chatItem).contact.lxmf_address }}
+ </div>
+ <div
+ v-if="cv.getParsedItems(chatItem).contact.lxst_address"
+ class="text-[9px] font-mono truncate"
+ :class="
+ chatItem.is_outbound
+ ? cv.isThemeOutboundBubble(chatItem)
+ ? 'text-white/60'
+ : 'text-gray-400 dark:text-zinc-500'
+ : 'text-gray-400 dark:text-zinc-500'
+ "
+ >
+ LXST: {{ cv.getParsedItems(chatItem).contact.lxst_address }}
+ </div>
+ </div>
+ </div>
+ <button
+ v-if="!chatItem.is_outbound"
+ type="button"
+ class="w-full py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-xs font-bold transition-colors shadow-xs"
+ @click="
+ cv.addContact(
+ cv.getParsedItems(chatItem).contact.name,
+ cv.getParsedItems(chatItem).contact.hash,
+ cv.getParsedItems(chatItem).contact.lxmf_address,
+ cv.getParsedItems(chatItem).contact.lxst_address
+ )
+ "
+ >
+ Add to Contacts
+ </button>
</div>
- <div class="my-auto">
- <MaterialDesignIcon icon-name="download" class="size-5" />
- </div>
- </a>
- </div>
- <!-- commands -->
- <div v-if="chatItem.lxmf_message.fields?.commands" class="space-y-2 mt-1">
- <div v-for="(command, index) in chatItem.lxmf_message.fields.commands" :key="index">
+ <!-- paper message auto-conversion -->
<div
- v-if="command['0x01'] || command['1'] || command['0x1']"
- class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
+ v-if="cv.getParsedItems(chatItem).paperMessage"
+ class="flex flex-col gap-2 p-3 rounded-xl border"
:class="
chatItem.is_outbound
- ? cv.outboundEmbeddedCardClass(chatItem)
- : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
+ ? cv.isThemeOutboundBubble(chatItem)
+ ? 'bg-white/10 border-white/20'
+ : 'bg-black/5 border-black/10'
+ : 'bg-emerald-50 dark:bg-black/60 border-emerald-100 dark:border-zinc-700/50'
"
>
- <MaterialDesignIcon icon-name="crosshairs-question" class="size-5" />
- <div class="text-left">
- <div class="font-bold text-xs uppercase tracking-wider opacity-80">
- {{ $t("messages.location_requested") }}
- </div>
- <div v-if="!chatItem.is_outbound" class="text-[10px] opacity-70">
- Peer is requesting your location
- </div>
+ <div
+ class="flex items-center gap-2"
+ :class="
+ chatItem.is_outbound
+ ? cv.isThemeOutboundBubble(chatItem)
+ ? 'text-white'
+ : 'text-gray-800 dark:text-zinc-200'
+ : 'text-emerald-700 dark:text-emerald-400'
+ "
+ >
+ <MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
+ <span class="text-sm font-bold">Paper Message detected</span>
</div>
+ <p
+ class="text-xs leading-relaxed"
+ :class="
+ chatItem.is_outbound
+ ? cv.isThemeOutboundBubble(chatItem)
+ ? 'text-white/80'
+ : 'text-gray-600 dark:text-zinc-400'
+ : 'text-emerald-600/80 dark:text-zinc-400'
+ "
+ >
+ This message contains a signed LXMF URI that can be ingested into your conversations.
+ </p>
+ <button
+ v-if="!chatItem.is_outbound"
+ type="button"
+ class="w-full py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-xs font-bold transition-colors shadow-xs"
+ @click="cv.ingestPaperMessage(cv.getParsedItems(chatItem).paperMessage)"
+ >
+ Ingest Message
+ </button>
+ </div>
+
+ <div
+ v-if="cv.getParsedItems(chatItem).mapLink"
+ class="flex flex-col gap-2 p-3 rounded-xl bg-sky-50 dark:bg-sky-950/30 border border-sky-200 dark:border-sky-800/50"
+ >
+ <div class="flex items-center gap-2 text-sky-800 dark:text-sky-300">
+ <MaterialDesignIcon icon-name="map-marker-radius" class="size-5" />
+ <span class="text-sm font-bold">{{
+ cv.getParsedItems(chatItem).mapLink.kind === "ping"
+ ? $t("messages.map_link_ping_title")
+ : $t("messages.map_link_share_title")
+ }}</span>
+ </div>
+ <div class="text-[10px] font-mono text-sky-900/80 dark:text-sky-200/90 break-all">
+ {{ cv.getParsedItems(chatItem).mapLink.parsed.lat.toFixed(5) }},
+ {{ cv.getParsedItems(chatItem).mapLink.parsed.lon.toFixed(5) }}
+ (z{{ cv.getParsedItems(chatItem).mapLink.parsed.zoom }})
+ </div>
+ <button
+ type="button"
+ class="w-full py-2 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-colors shadow-xs"
+ @click="cv.openMapShareFromParsed(cv.getParsedItems(chatItem).mapLink.parsed)"
+ >
+ {{ $t("messages.map_link_open") }}
+ </button>
+ <button
+ type="button"
+ class="w-full py-2 bg-white dark:bg-zinc-900 border border-sky-200 dark:border-sky-800 text-sky-800 dark:text-sky-200 rounded-lg text-xs font-bold"
+ @click="cv.copyMapShareUri(cv.getParsedItems(chatItem).mapLink.uri)"
+ >
+ {{ $t("messages.map_link_copy_uri") }}
+ </button>
</div>
</div>
- </div>
- <!-- telemetry / location field -->
- <div v-if="chatItem.lxmf_message.fields?.telemetry" class="pb-1 mt-1 space-y-2">
- <div class="flex flex-wrap gap-2">
- <button
- v-if="chatItem.lxmf_message.fields.telemetry.location"
- type="button"
- class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
+ <!-- audio field -->
+ <div v-if="chatItem.lxmf_message.fields?.audio" class="pb-1">
+ <!-- audio is loaded -->
+ <AudioWaveformPlayer
+ v-if="cv.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]"
+ :src="cv.lxmfMessageAudioAttachmentCache[chatItem.lxmf_message.hash]"
+ :is-outbound="chatItem.is_outbound"
+ />
+
+ <!-- audio is not yet loaded -->
+ <div
+ v-else
+ class="flex items-center justify-center p-2 rounded-xl bg-gray-50/50 dark:bg-zinc-800/50 border border-gray-100 dark:border-zinc-800 min-h-[54px]"
+ >
+ <div class="flex items-center gap-2">
+ <div
+ class="size-4 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
+ ></div>
+ <span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">{{
+ $t("messages.downloading")
+ }}</span>
+ </div>
+ </div>
+
+ <div
+ class="text-[10px] mt-1 text-right opacity-60"
+ :class="cv.outboundAttachmentCaptionClass(chatItem)"
+ >
+ Voice Note •
+ {{ cv.formatAttachmentSize(chatItem.lxmf_message.fields.audio, "audio") }}
+ </div>
+ </div>
+
+ <!-- file attachment fields -->
+ <div v-if="cv.hasFileAttachments(chatItem.lxmf_message)" class="space-y-2 mt-1">
+ <a
+ v-for="(file_attachment, index) of chatItem.lxmf_message.fields?.file_attachments ?? []"
+ :key="file_attachment.file_name"
+ target="_blank"
+ :download="file_attachment.file_name"
+ :href="`/api/v1/lxmf-messages/attachment/${chatItem.lxmf_message.hash}/file?file_index=${index}`"
+ class="flex items-center gap-3 border rounded-lg px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
:class="
chatItem.is_outbound
? cv.outboundEmbeddedCardClass(chatItem)
- : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
+ : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300 border-gray-200/60 dark:border-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-800'
"
- @click="cv.viewLocationOnMap(chatItem.lxmf_message.fields.telemetry.location)"
+ @click.stop
>
- <MaterialDesignIcon icon-name="map-marker" class="size-5" />
- <div class="text-left">
- <div class="font-bold text-[10px] uppercase tracking-wider opacity-80">Location</div>
- <div class="text-[9px] font-mono opacity-70">
- {{ chatItem.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
- {{ chatItem.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
+ <div class="my-auto">
+ <MaterialDesignIcon icon-name="paperclip" class="size-5" />
+ </div>
+ <div class="flex-1 min-w-0">
+ <div class="truncate text-xs font-bold">
+ {{ file_attachment.file_name }}
+ </div>
+ <div
+ class="text-[10px] font-normal"
+ :class="
+ chatItem.is_outbound
+ ? cv.outboundEmbeddedSecondaryTextClass(chatItem)
+ : 'text-gray-500 dark:text-zinc-400'
+ "
+ >
+ {{ cv.formatAttachmentSize(file_attachment, "file") }}
</div>
</div>
- </button>
+ <div class="my-auto">
+ <MaterialDesignIcon icon-name="download" class="size-5" />
+ </div>
+ </a>
+ </div>
- <!-- Live Track Toggle Button (only for incoming) -->
- <button
- v-if="!chatItem.is_outbound"
- type="button"
- class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
- :class="[
- cv.selectedPeer?.is_tracking
- ? 'bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30 shadow-inner'
- : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
- ]"
- @click="cv.toggleTracking()"
- >
- <MaterialDesignIcon
- :icon-name="cv.selectedPeer?.is_tracking ? 'radar' : 'crosshairs'"
- class="size-5"
- :class="{
- 'animate-pulse text-blue-500': cv.selectedPeer?.is_tracking,
- }"
- />
- <div class="text-left">
- <div class="font-bold text-[10px] uppercase tracking-wider opacity-80">
- {{ cv.selectedPeer?.is_tracking ? "Tracking Active" : "Live Track" }}
- </div>
- <div class="text-[9px] opacity-70">
- {{
- cv.selectedPeer?.is_tracking
- ? "Auto-requesting location"
- : "Enable live tracking"
- }}
+ <!-- commands -->
+ <div v-if="chatItem.lxmf_message.fields?.commands" class="space-y-2 mt-1">
+ <div v-for="(command, index) in chatItem.lxmf_message.fields.commands" :key="index">
+ <div
+ v-if="command['0x01'] || command['1'] || command['0x1']"
+ class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
+ :class="
+ chatItem.is_outbound
+ ? cv.outboundEmbeddedCardClass(chatItem)
+ : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
+ "
+ >
+ <MaterialDesignIcon icon-name="crosshairs-question" class="size-5" />
+ <div class="text-left">
+ <div class="font-bold text-xs uppercase tracking-wider opacity-80">
+ {{ $t("messages.location_requested") }}
+ </div>
+ <div v-if="!chatItem.is_outbound" class="text-[10px] opacity-70">
+ Peer is requesting your location
+ </div>
</div>
</div>
- </button>
+ </div>
</div>
- <!-- other sensor data if available -->
- <div
- v-if="
- chatItem.lxmf_message.fields.telemetry.battery ||
- chatItem.lxmf_message.fields.telemetry.physical_link
- "
- class="flex gap-3 px-1"
- >
- <div
- v-if="chatItem.lxmf_message.fields.telemetry.battery"
- class="flex items-center gap-1 opacity-60 text-[10px]"
- >
- <MaterialDesignIcon icon-name="battery" class="size-3" />
- <span>{{ chatItem.lxmf_message.fields.telemetry.battery.charge_percent }}%</span>
+ <!-- telemetry / location field -->
+ <div v-if="chatItem.lxmf_message.fields?.telemetry" class="pb-1 mt-1 space-y-2">
+ <div class="flex flex-wrap gap-2">
+ <button
+ v-if="chatItem.lxmf_message.fields.telemetry.location"
+ type="button"
+ class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
+ :class="
+ chatItem.is_outbound
+ ? cv.outboundEmbeddedCardClass(chatItem)
+ : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
+ "
+ @click="cv.viewLocationOnMap(chatItem.lxmf_message.fields.telemetry.location)"
+ >
+ <MaterialDesignIcon icon-name="map-marker" class="size-5" />
+ <div class="text-left">
+ <div class="font-bold text-[10px] uppercase tracking-wider opacity-80">
+ Location
+ </div>
+ <div class="text-[9px] font-mono opacity-70">
+ {{ chatItem.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
+ {{ chatItem.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
+ </div>
+ </div>
+ </button>
+
+ <!-- Live Track Toggle Button (only for incoming) -->
+ <button
+ v-if="!chatItem.is_outbound"
+ type="button"
+ class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
+ :class="[
+ cv.selectedPeer?.is_tracking
+ ? 'bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30 shadow-inner'
+ : 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
+ ]"
+ @click="cv.toggleTracking()"
+ >
+ <MaterialDesignIcon
+ :icon-name="cv.selectedPeer?.is_tracking ? 'radar' : 'crosshairs'"
+ class="size-5"
+ :class="{
+ 'animate-pulse text-blue-500': cv.selectedPeer?.is_tracking,
+ }"
+ />
+ <div class="text-left">
+ <div class="font-bold text-[10px] uppercase tracking-wider opacity-80">
+ {{ cv.selectedPeer?.is_tracking ? "Tracking Active" : "Live Track" }}
+ </div>
+ <div class="text-[9px] opacity-70">
+ {{
+ cv.selectedPeer?.is_tracking
+ ? "Auto-requesting location"
+ : "Enable live tracking"
+ }}
+ </div>
+ </div>
+ </button>
</div>
+
+ <!-- other sensor data if available -->
<div
- v-if="chatItem.lxmf_message.fields.telemetry.physical_link"
- class="flex items-center gap-1 opacity-60 text-[10px]"
+ v-if="
+ chatItem.lxmf_message.fields.telemetry.battery ||
+ chatItem.lxmf_message.fields.telemetry.physical_link
+ "
+ class="flex gap-3 px-1"
>
- <MaterialDesignIcon icon-name="antenna" class="size-3" />
- <span>SNR: {{ chatItem.lxmf_message.fields.telemetry.physical_link.snr }}dB</span>
+ <div
+ v-if="chatItem.lxmf_message.fields.telemetry.battery"
+ class="flex items-center gap-1 opacity-60 text-[10px]"
+ >
+ <MaterialDesignIcon icon-name="battery" class="size-3" />
+ <span>{{ chatItem.lxmf_message.fields.telemetry.battery.charge_percent }}%</span>
+ </div>
+ <div
+ v-if="chatItem.lxmf_message.fields.telemetry.physical_link"
+ class="flex items-center gap-1 opacity-60 text-[10px]"
+ >
+ <MaterialDesignIcon icon-name="antenna" class="size-3" />
+ <span>SNR: {{ chatItem.lxmf_message.fields.telemetry.physical_link.snr }}dB</span>
+ </div>
</div>
</div>
- </div>
- <!-- message footer: timestamp and status icons -->
- <div
- v-if="entry.showTimestamp !== false || chatItem.is_outbound"
- class="flex items-center justify-end gap-1.5 mt-1.5 select-none h-3"
- >
- <span
- v-if="entry.showTimestamp !== false"
- class="text-[9px] opacity-80 font-medium"
- :class="cv.outboundBubbleFooterTimeClass(chatItem)"
- :title="cv.getMessageInfoLines(chatItem.lxmf_message, chatItem.is_outbound).join('\n')"
+ <!-- message footer: timestamp and status icons -->
+ <div
+ v-if="entry.showTimestamp !== false || chatItem.is_outbound"
+ class="flex items-center justify-end gap-1.5 mt-1.5 select-none h-3"
>
- {{ cv.formatTimeAgo(chatItem.lxmf_message.created_at) }}
- </span>
-
- <!-- outbound status icons -->
- <div v-if="chatItem.is_outbound" class="flex items-center gap-1">
- <span
- v-if="cv.isOpportunisticDeferredDelivery(chatItem.lxmf_message)"
- class="text-[9px] font-bold uppercase tracking-wider"
- :class="
- cv.isThemeOutboundBubble(chatItem)
- ? 'text-amber-800 dark:text-amber-300'
- : 'text-amber-200'
- "
- >
- {{ $t("messages.opportunistic_deferred_label") }}
- </span>
<span
- v-else-if="['failed', 'cancelled', 'rejected'].includes(chatItem.lxmf_message.state)"
- class="text-[9px] font-bold uppercase tracking-wider text-white"
+ v-if="entry.showTimestamp !== false"
+ class="text-[9px] opacity-80 font-medium"
+ :class="cv.outboundBubbleFooterTimeClass(chatItem)"
+ :title="cv.getMessageInfoLines(chatItem.lxmf_message, chatItem.is_outbound).join('\n')"
>
- {{
- chatItem.lxmf_message.state === "rejected"
- ? "Rejected"
- : $t("messages.failed_waiting_announce")
- }}
+ {{ cv.formatTimeAgo(chatItem.lxmf_message.created_at) }}
</span>
+
+ <!-- outbound status icons -->
+ <div v-if="chatItem.is_outbound" class="flex items-center gap-1">
+ <span
+ v-if="cv.isOpportunisticDeferredDelivery(chatItem.lxmf_message)"
+ class="text-[9px] font-bold uppercase tracking-wider"
+ :class="
+ cv.isThemeOutboundBubble(chatItem)
+ ? 'text-amber-800 dark:text-amber-300'
+ : 'text-amber-200'
+ "
+ >
+ {{ $t("messages.opportunistic_deferred_label") }}
+ </span>
+ <span
+ v-else-if="['failed', 'cancelled', 'rejected'].includes(chatItem.lxmf_message.state)"
+ class="text-[9px] font-bold uppercase tracking-wider text-white"
+ >
+ {{
+ chatItem.lxmf_message.state === "rejected"
+ ? "Rejected"
+ : $t("messages.failed_waiting_announce")
+ }}
+ </span>
+ <button
+ v-if="['failed', 'cancelled'].includes(chatItem.lxmf_message.state)"
+ type="button"
+ class="ml-0.5 p-0.5 rounded-sm hover:bg-white/20 transition-colors"
+ title="Retry sending"
+ @click.stop="cv.retrySendingMessage(chatItem)"
+ >
+ <MaterialDesignIcon icon-name="refresh" class="size-3 text-white" />
+ </button>
+
+ <!-- delivered: double check -->
+ <MaterialDesignIcon
+ v-if="chatItem.lxmf_message.state === 'delivered'"
+ icon-name="check-all"
+ class="size-3"
+ :class="cv.outboundBubbleDeliveredIconClass(chatItem)"
+ title="Delivered"
+ />
+ <!-- sent: single check (include unknown for initial outbound when server confirmed creation) -->
+ <MaterialDesignIcon
+ v-else-if="['sent', 'propagated', 'unknown'].includes(chatItem.lxmf_message.state)"
+ icon-name="check"
+ class="size-3"
+ :class="cv.outboundBubbleSentCheckIconClass(chatItem)"
+ :title="cv.outboundSentStatusTitle(chatItem.lxmf_message)"
+ />
+ <svg
+ v-if="cv.showRichOutboundPendingUi(chatItem) && cv.isOutboundPendingForUi(chatItem)"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ class="animate-spin size-3.5 shrink-0"
+ :class="cv.outboundSendingStatusIconClass(chatItem)"
+ :title="cv.outboundBubbleStatusHoverTitle(chatItem.lxmf_message)"
+ >
+ <title>
+ {{ cv.outboundBubbleStatusHoverTitle(chatItem.lxmf_message) }}
+ </title>
+ <circle
+ class="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ stroke-width="4"
+ ></circle>
+ <path
+ class="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ ></path>
+ </svg>
+ <MaterialDesignIcon
+ v-else-if="cv.isOutboundPendingForUi(chatItem)"
+ icon-name="check"
+ class="size-3"
+ :class="cv.outboundBubblePendingCheckIconClass(chatItem)"
+ :title="$t('messages.sending_ellipsis')"
+ />
+ <div
+ v-else-if="cv.isOpportunisticDeferredDelivery(chatItem.lxmf_message)"
+ class="relative flex size-3.5 shrink-0 items-center justify-center rounded-full border border-dashed border-amber-200/85"
+ :title="$t('messages.opportunistic_deferred_tooltip')"
+ >
+ <MaterialDesignIcon icon-name="clock-outline" class="size-2.5 text-amber-200/95" />
+ </div>
+ <!-- failed/cancelled/rejected: alert -->
+ <MaterialDesignIcon
+ v-else-if="['failed', 'cancelled', 'rejected'].includes(chatItem.lxmf_message.state)"
+ icon-name="alert-circle-outline"
+ class="size-3 text-white"
+ :title="cv.outboundBubbleFailedTitle(chatItem.lxmf_message)"
+ />
+ </div>
+ </div>
+ </div>
+
+ <OutboundTransferProgressFooter
+ v-if="chatItem.is_outbound && !chatItem.lxmf_message.fields?.image"
+ :lxmf-message="chatItem.lxmf_message"
+ :chat-item="chatItem"
+ :cv="cv"
+ variant="bubble"
+ />
+
+ <!-- actions (expanded) -->
+ <div
+ v-if="chatItem.is_actions_expanded"
+ class="border-t px-4 py-2.5"
+ :class="cv.outboundExpandedActionsShellClass(chatItem)"
+ >
+ <div class="flex items-center gap-2">
<button
- v-if="['failed', 'cancelled'].includes(chatItem.lxmf_message.state)"
type="button"
- class="ml-0.5 p-0.5 rounded-sm hover:bg-white/20 transition-colors"
- title="Retry sending"
- @click.stop="cv.retrySendingMessage(chatItem)"
+ class="inline-flex items-center gap-x-1.5 rounded-lg bg-blue-500 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-blue-600 transition-colors"
+ @click.stop="cv.replyToMessage(chatItem)"
>
- <MaterialDesignIcon icon-name="refresh" class="size-3 text-white" />
+ {{ $t("messages.reply") }}
</button>
-
- <!-- delivered: double check -->
- <MaterialDesignIcon
- v-if="chatItem.lxmf_message.state === 'delivered'"
- icon-name="check-all"
- class="size-3"
- :class="cv.outboundBubbleDeliveredIconClass(chatItem)"
- title="Delivered"
- />
- <!-- sent: single check (include unknown for initial outbound when server confirmed creation) -->
- <MaterialDesignIcon
- v-else-if="['sent', 'propagated', 'unknown'].includes(chatItem.lxmf_message.state)"
- icon-name="check"
- class="size-3"
- :class="cv.outboundBubbleSentCheckIconClass(chatItem)"
- :title="cv.outboundSentStatusTitle(chatItem.lxmf_message)"
- />
- <svg
- v-if="cv.showRichOutboundPendingUi(chatItem) && cv.isOutboundPendingForUi(chatItem)"
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- class="animate-spin size-3.5 shrink-0"
- :class="cv.outboundSendingStatusIconClass(chatItem)"
- :title="cv.outboundBubbleStatusHoverTitle(chatItem.lxmf_message)"
+ <button
+ type="button"
+ class="inline-flex items-center gap-x-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-red-600 transition-colors"
+ @click.stop="cv.deleteChatItem(chatItem)"
>
- <title>
- {{ cv.outboundBubbleStatusHoverTitle(chatItem.lxmf_message) }}
- </title>
- <circle
- class="opacity-25"
- cx="12"
- cy="12"
- r="10"
- stroke="currentColor"
- stroke-width="4"
- ></circle>
- <path
- class="opacity-75"
- fill="currentColor"
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
- ></path>
- </svg>
- <MaterialDesignIcon
- v-else-if="cv.isOutboundPendingForUi(chatItem)"
- icon-name="check"
- class="size-3"
- :class="cv.outboundBubblePendingCheckIconClass(chatItem)"
- :title="$t('messages.sending_ellipsis')"
- />
- <div
- v-else-if="cv.isOpportunisticDeferredDelivery(chatItem.lxmf_message)"
- class="relative flex size-3.5 shrink-0 items-center justify-center rounded-full border border-dashed border-amber-200/85"
- :title="$t('messages.opportunistic_deferred_tooltip')"
+ Delete
+ </button>
+ <button
+ type="button"
+ class="inline-flex items-center gap-x-1.5 rounded-lg bg-gray-600 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-gray-700 transition-colors"
+ @click.stop="cv.showRawMessage(chatItem)"
>
- <MaterialDesignIcon icon-name="clock-outline" class="size-2.5 text-amber-200/95" />
- </div>
- <!-- failed/cancelled/rejected: alert -->
- <MaterialDesignIcon
- v-else-if="['failed', 'cancelled', 'rejected'].includes(chatItem.lxmf_message.state)"
- icon-name="alert-circle-outline"
- class="size-3 text-white"
- :title="cv.outboundBubbleFailedTitle(chatItem.lxmf_message)"
- />
+ Raw LXM
+ </button>
</div>
</div>
</div>
-
- <!-- actions (expanded) -->
- <div
- v-if="chatItem.is_actions_expanded"
- class="border-t px-4 py-2.5"
- :class="cv.outboundExpandedActionsShellClass(chatItem)"
- >
- <div class="flex items-center gap-2">
- <button
- type="button"
- class="inline-flex items-center gap-x-1.5 rounded-lg bg-blue-500 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-blue-600 transition-colors"
- @click.stop="cv.replyToMessage(chatItem)"
- >
- {{ $t("messages.reply") }}
- </button>
- <button
- type="button"
- class="inline-flex items-center gap-x-1.5 rounded-lg bg-red-500 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-red-600 transition-colors"
- @click.stop="cv.deleteChatItem(chatItem)"
- >
- Delete
- </button>
- <button
- type="button"
- class="inline-flex items-center gap-x-1.5 rounded-lg bg-gray-600 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-gray-700 transition-colors"
- @click.stop="cv.showRawMessage(chatItem)"
- >
- Raw LXM
- </button>
- </div>
- </div>
- </div>
-
- <div
- v-if="chatItem.is_outbound && cv.outboundTransferProgressPercent(chatItem.lxmf_message) !== null"
- class="flex items-center gap-2 justify-end mt-1 w-full min-w-[8rem] max-w-[min(280px,85vw)]"
- >
- <div class="flex-1 h-1.5 rounded-full bg-gray-200/90 dark:bg-zinc-700/90 overflow-hidden">
- <div
- class="h-full rounded-full bg-blue-500 dark:bg-blue-400 transition-all duration-300"
- :style="{ width: cv.outboundTransferProgressPercent(chatItem.lxmf_message) + '%' }"
- ></div>
- </div>
- <span class="text-[11px] font-semibold tabular-nums text-gray-500 dark:text-zinc-400 shrink-0">
- {{ cv.outboundSendingProgressLabel(chatItem.lxmf_message) }}
- </span>
- </div>
-
- <div
- v-if="chatItem.lxmf_message.reactions?.length || !chatItem.lxmf_message.is_reaction"
- class="flex w-fit max-w-full flex-wrap items-center gap-0.5 px-0.5"
- :class="[
- chatItem.is_outbound ? 'self-end justify-end' : 'self-start justify-start',
- (chatItem.lxmf_message.reactions?.length ?? 0) > 0 ? 'mt-0.5' : 'mt-0',
- ]"
- >
- <span
- v-for="(r, ridx) in chatItem.lxmf_message.reactions"
- :key="r.reactionHash || ridx"
- class="inline-flex min-h-4.5 min-w-4.5 cursor-default select-none items-center justify-center rounded-full border border-gray-200/90 bg-white px-1 py-0 text-sm leading-none shadow-xs dark:border-zinc-600/90 dark:bg-zinc-900"
- :style="{
- order: chatItem.is_outbound ? ridx + 2 : ridx + 1,
- }"
- :title="cv.reactionReactorLabel(r.sender)"
- >{{ r.emoji }}</span
- >
- <button
- v-if="!chatItem.lxmf_message.is_reaction"
- type="button"
- class="inline-flex items-center justify-center rounded-full border border-dashed border-gray-300 bg-transparent text-xs leading-none text-gray-400 hover:border-gray-400 hover:text-gray-600 hover:bg-gray-50 dark:border-zinc-600 dark:text-zinc-500 dark:hover:border-zinc-500 dark:hover:text-zinc-300 dark:hover:bg-zinc-800 transition-colors opacity-0 group-hover:opacity-100"
- :class="
- (chatItem.lxmf_message.reactions?.length ?? 0) > 0
- ? 'min-h-4.5 min-w-4.5 px-1 py-0'
- : 'h-4 w-4 min-h-0 p-0'
+ <MessageReactionsOverlay
+ v-if="chatItem.lxmf_message.reactions?.length || !chatItem.lxmf_message.is_reaction"
+ :reactions="chatItem.lxmf_message.reactions"
+ :is-outbound="chatItem.is_outbound"
+ :chat-item="chatItem"
+ :cv="cv"
+ :show-react-button="!chatItem.lxmf_message.is_reaction"
+ :elevated="
+ chatItem.is_outbound &&
+ cv.showOutboundTransferProgress(chatItem.lxmf_message) &&
+ !chatItem.lxmf_message.fields?.image
"
- :style="{
- order: chatItem.is_outbound ? 1 : (chatItem.lxmf_message.reactions?.length ?? 0) + 1,
- }"
- :title="$t('messages.react')"
- @click.stop="cv.openReactionPicker(chatItem)"
- >
- <MaterialDesignIcon icon-name="emoticon-plus-outline" class="size-3" />
- </button>
+ />
</div>
<!-- expanded message details -->
@@ -1250,6 +1234,8 @@
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import AudioWaveformPlayer from "./AudioWaveformPlayer.vue";
+import OutboundTransferProgressFooter from "./OutboundTransferProgressFooter.vue";
+import MessageReactionsOverlay from "./MessageReactionsOverlay.vue";
import StickerView from "../stickers/StickerView.vue";
import InViewAnimatedImg from "./InViewAnimatedImg.vue";
import { isAnimatedRasterType } from "../../js/inViewObserver.js";
@@ -1259,6 +1245,8 @@ export default {
components: {
MaterialDesignIcon,
AudioWaveformPlayer,
+ OutboundTransferProgressFooter,
+ MessageReactionsOverlay,
StickerView,
InViewAnimatedImg,
},

diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue b/meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue
index 1d311e54..afe3a27e 100644
--- a/meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue
@@ -7,7 +7,7 @@
:key="groups[v.index]?.key ?? v.index"
:ref="measureElement"
:data-index="v.index"
- class="absolute left-0 top-0 w-full box-border px-0"
+ class="absolute left-0 top-0 w-full box-border px-0 [overflow-anchor:none]"
:style="{ transform: `translateY(${v.start}px)` }"
>
<ConversationMessageEntry :entry="groups[v.index]" :cv="cv" />

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 7115a59c..563b0fc0 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -183,7 +183,7 @@
:class="useVirtualMessageList ? 'relative flex flex-col' : ''"
>
<template v-if="!useVirtualMessageList">
- <div class="flex flex-col flex-col-reverse min-w-0">
+ <div class="flex flex-col flex-col-reverse min-w-0 [overflow-anchor:none]">
<template
v-for="entry in selectedPeerChatDisplayGroupsNewestFirstAugmented"
:key="entry.key"
@@ -1716,6 +1716,9 @@ import {
import {
isTelemetryOnly as isTelemetryOnlyMessage,
hasRenderableContent as messageHasRenderableContent,
+ hasFileAttachments as messageHasFileAttachments,
+ hasMessageBubble as computeHasMessageBubble,
+ isFileOnlyMessage as computeIsFileOnlyMessage,
isImageOnlyMessage as computeIsImageOnlyMessage,
collectImageFilesFromDataTransfer as collectImagesFromDataTransfer,
extractClipboardImageFiles,
@@ -4304,6 +4307,12 @@ export default {
return true;
});
},
+ showOutboundTransferProgress(lxmfMessage) {
+ if (!GlobalState.outboundTransferProgressEnabled) {
+ return false;
+ }
+ return this.outboundTransferProgressPercent(lxmfMessage) !== null;
+ },
outboundTransferProgressPercent(lxmfMessage) {
if (!lxmfMessage || lxmfMessage._pendingPathfinding) {
return null;
@@ -4322,6 +4331,58 @@ export default {
const pct = this.outboundTransferProgressPercent(lxmfMessage);
return pct === null ? null : `${pct}%`;
},
+ outboundTransferElapsedSeconds(lxmfMessage, chatItem) {
+ void this.sendStatusUiMs;
+ const createdAt = chatItem?.created_at || lxmfMessage?.created_at;
+ if (!createdAt) {
+ return 0;
+ }
+ const createdMs = new Date(createdAt).getTime();
+ if (!createdMs) {
+ return 0;
+ }
+ return Math.max(0, Math.floor((this.sendStatusUiMs - createdMs) / 1000));
+ },
+ outboundTransferSpeedBytesPerSecond(lxmfMessage, chatItem) {
+ const progress = Number(lxmfMessage?.progress ?? 0);
+ const totalBytes = Utils.lxmfMessageTransferTotalBytes(lxmfMessage, (value) =>
+ this.base64ByteLength(value)
+ );
+ const elapsedSeconds = this.outboundTransferElapsedSeconds(lxmfMessage, chatItem);
+ if (totalBytes <= 0 || progress <= 0 || elapsedSeconds <= 0) {
+ return 0;
+ }
+ const transferredBytes = (progress / 100) * totalBytes;
+ return transferredBytes / elapsedSeconds;
+ },
+ outboundTransferHopsLabel(lxmfMessage) {
+ const hops = lxmfMessage?.path_hops_at_send;
+ if (hops == null) {
+ return null;
+ }
+ const count = Number(hops);
+ if (!Number.isFinite(count)) {
+ return null;
+ }
+ if (count === 1) {
+ return this.$t("messages.transfer_progress_hop_one");
+ }
+ return this.$t("messages.transfer_progress_hops", { count });
+ },
+ outboundTransferStatsLabel(lxmfMessage, chatItem) {
+ void GlobalState.outboundTransferProgressEnabled;
+ if (!this.showOutboundTransferProgress(lxmfMessage)) {
+ return null;
+ }
+ const bytesPerSecond = this.outboundTransferSpeedBytesPerSecond(lxmfMessage, chatItem);
+ const parts = [Utils.formatBytesPerSecond(bytesPerSecond)];
+ const hopsLabel = this.outboundTransferHopsLabel(lxmfMessage);
+ if (hopsLabel) {
+ parts.push(hopsLabel);
+ }
+ parts.push(Utils.formatCountupDuration(this.outboundTransferElapsedSeconds(lxmfMessage, chatItem)));
+ return parts.join(" · ");
+ },
outboundSendingStatusTooltip(lxmfMessage) {
if (!lxmfMessage) {
return "";
@@ -5452,6 +5513,15 @@ export default {
hasRenderableContent(msg) {
return messageHasRenderableContent(msg);
},
+ hasFileAttachments(msg) {
+ return messageHasFileAttachments(msg);
+ },
+ hasMessageBubble(chatItem) {
+ return computeHasMessageBubble(chatItem, (item) => this.shouldHideAutoImageCaption(item));
+ },
+ isFileOnlyMessage(chatItem) {
+ return computeIsFileOnlyMessage(chatItem, (item) => this.shouldHideAutoImageCaption(item));
+ },
isImageOnlyMessage(chatItem) {
return computeIsImageOnlyMessage(chatItem, (item) => this.shouldHideAutoImageCaption(item));
},

diff --git a/meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue b/meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue
new file mode 100644
index 00000000..82069472
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue
@@ -0,0 +1,73 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div
+ v-if="(reactions?.length ?? 0) > 0 || showReactButton"
+ class="pointer-events-auto absolute z-20 flex w-fit max-w-[calc(100%-0.75rem)] flex-wrap items-center gap-0.5"
+ :class="[
+ isOutbound ? 'right-2 justify-end' : 'left-2 justify-start',
+ elevated ? 'bottom-9 translate-y-0' : 'bottom-0 translate-y-1/2',
+ ]"
+ >
+ <span
+ v-for="(r, ridx) in reactions"
+ :key="r.reactionHash || ridx"
+ class="inline-flex min-h-4.5 min-w-4.5 cursor-default select-none items-center justify-center rounded-full border border-gray-200/90 bg-white px-1 py-0 text-sm leading-none shadow-sm ring-1 ring-white/90 dark:border-zinc-600/90 dark:bg-zinc-900 dark:ring-zinc-800/90"
+ :style="{
+ order: isOutbound ? ridx + 2 : ridx + 1,
+ }"
+ :title="cv.reactionReactorLabel(r.sender)"
+ >{{ r.emoji }}</span
+ >
+ <button
+ v-if="showReactButton"
+ type="button"
+ class="inline-flex items-center justify-center rounded-full border border-dashed border-gray-300 bg-white/95 text-xs leading-none text-gray-400 opacity-0 shadow-sm ring-1 ring-white/90 hover:border-gray-400 hover:text-gray-600 hover:bg-gray-50 group-hover:opacity-100 dark:border-zinc-600 dark:bg-zinc-900/95 dark:text-zinc-500 dark:ring-zinc-800/90 dark:hover:border-zinc-500 dark:hover:text-zinc-300 dark:hover:bg-zinc-800 transition-colors"
+ :class="(reactions?.length ?? 0) > 0 ? 'min-h-4.5 min-w-4.5 px-1 py-0' : 'h-4 w-4 min-h-0 p-0'"
+ :style="{
+ order: isOutbound ? 1 : (reactions?.length ?? 0) + 1,
+ }"
+ :title="$t('messages.react')"
+ @click.stop="cv.openReactionPicker(chatItem)"
+ >
+ <MaterialDesignIcon icon-name="emoticon-plus-outline" class="size-3" />
+ </button>
+ </div>
+</template>
+
+<script>
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+
+export default {
+ name: "MessageReactionsOverlay",
+ components: {
+ MaterialDesignIcon,
+ },
+ props: {
+ reactions: {
+ type: Array,
+ default: () => [],
+ },
+ isOutbound: {
+ type: Boolean,
+ default: false,
+ },
+ chatItem: {
+ type: Object,
+ required: true,
+ },
+ cv: {
+ type: Object,
+ required: true,
+ },
+ showReactButton: {
+ type: Boolean,
+ default: true,
+ },
+ elevated: {
+ type: Boolean,
+ default: false,
+ },
+ },
+};
+</script>

diff --git a/meshchatx/src/frontend/components/messages/OutboundTransferProgressFooter.vue b/meshchatx/src/frontend/components/messages/OutboundTransferProgressFooter.vue
new file mode 100644
index 00000000..cd3b9476
--- /dev/null
+++ b/meshchatx/src/frontend/components/messages/OutboundTransferProgressFooter.vue
@@ -0,0 +1,69 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div v-if="cv.showOutboundTransferProgress(lxmfMessage)" class="w-full shrink-0" :class="shellClass">
+ <div class="flex items-center gap-1.5">
+ <div class="flex-1 h-1 rounded-full overflow-hidden" :class="trackClass">
+ <div
+ class="h-full rounded-full transition-all duration-300"
+ :class="barClass"
+ :style="{ width: cv.outboundTransferProgressPercent(lxmfMessage) + '%' }"
+ ></div>
+ </div>
+ <span class="text-[10px] font-semibold tabular-nums shrink-0" :class="percentClass">
+ {{ cv.outboundSendingProgressLabel(lxmfMessage) }}
+ </span>
+ </div>
+ <div v-if="statsLabel" class="text-[9px] tabular-nums truncate mt-0.5 leading-tight" :class="statsClass">
+ {{ statsLabel }}
+ </div>
+ </div>
+</template>
+
+<script>
+export default {
+ name: "OutboundTransferProgressFooter",
+ props: {
+ lxmfMessage: {
+ type: Object,
+ required: true,
+ },
+ chatItem: {
+ type: Object,
+ required: true,
+ },
+ cv: {
+ type: Object,
+ required: true,
+ },
+ variant: {
+ type: String,
+ default: "bubble",
+ validator: (value) => ["bubble", "image"].includes(value),
+ },
+ },
+ computed: {
+ statsLabel() {
+ return this.cv.outboundTransferStatsLabel(this.lxmfMessage, this.chatItem);
+ },
+ shellClass() {
+ if (this.variant === "image") {
+ return "bg-black/75 backdrop-blur-md px-2.5 py-1.5";
+ }
+ return "border-t border-black/8 dark:border-white/10 bg-black/[0.04] dark:bg-white/[0.05] px-3 py-1.5";
+ },
+ trackClass() {
+ return this.variant === "image" ? "bg-white/20" : "bg-gray-200/90 dark:bg-zinc-700/90";
+ },
+ barClass() {
+ return this.variant === "image" ? "bg-white" : "bg-blue-500 dark:bg-blue-400";
+ },
+ percentClass() {
+ return this.variant === "image" ? "text-white/95" : "text-gray-500 dark:text-zinc-400";
+ },
+ statsClass() {
+ return this.variant === "image" ? "text-white/75" : "text-gray-400 dark:text-zinc-500";
+ },
+ },
+};
+</script>

diff --git a/meshchatx/src/frontend/components/messages/conversationMessageHelpers.js b/meshchatx/src/frontend/components/messages/conversationMessageHelpers.js
index b3690121..4f5e0ff7 100644
--- a/meshchatx/src/frontend/components/messages/conversationMessageHelpers.js
+++ b/meshchatx/src/frontend/components/messages/conversationMessageHelpers.js
@@ -2,13 +2,22 @@
* Pure helpers for LXMF message display predicates and clipboard/drag image extraction.
*/
+/**
+ * @param {object} msg
+ * @returns {boolean}
+ */
+export function hasFileAttachments(msg) {
+ const files = msg?.fields?.file_attachments;
+ return Array.isArray(files) && files.length > 0;
+}
+
/**
* @param {object} msg
* @returns {boolean}
*/
export function isTelemetryOnly(msg) {
const hasContent = msg.content && msg.content.trim() !== "";
- const hasAttachments = msg.fields?.image || msg.fields?.audio || msg.fields?.file_attachments;
+ const hasAttachments = msg.fields?.image || msg.fields?.audio || hasFileAttachments(msg);
const hasTelemetry = msg.fields?.telemetry || msg.fields?.telemetry_stream;
const hasCommands = msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"]);
@@ -23,12 +32,44 @@ export function hasRenderableContent(msg) {
if (msg.content && msg.content.trim() !== "") return true;
if (msg.fields?.image) return true;
if (msg.fields?.audio) return true;
- if (msg.fields?.file_attachments) return true;
+ if (hasFileAttachments(msg)) return true;
if (msg.fields?.telemetry || msg.fields?.telemetry_stream) return true;
if (msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"] || c["1"] || c["0x1"])) return true;
return false;
}
+/**
+ * @param {object} chatItem
+ * @param {(item: object) => boolean} shouldHideAutoImageCaption
+ * @returns {boolean}
+ */
+export function isFileOnlyMessage(chatItem, shouldHideAutoImageCaption) {
+ const msg = chatItem.lxmf_message;
+ if (!hasFileAttachments(msg)) return false;
+ if (msg.fields?.image || msg.fields?.audio) return false;
+ const content = (msg.content || "").trim();
+ if (content && !shouldHideAutoImageCaption(chatItem)) return false;
+ if (msg.reply_to_hash) return false;
+ if (msg.fields?.telemetry || msg.fields?.telemetry_stream) return false;
+ if (msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"] || c["1"] || c["0x1"])) return false;
+ return true;
+}
+
+/**
+ * @param {object} chatItem
+ * @param {(item: object) => boolean} shouldHideAutoImageCaption
+ * @returns {boolean}
+ */
+export function hasMessageBubble(chatItem, shouldHideAutoImageCaption) {
+ if (!chatItem?.lxmf_message) {
+ return false;
+ }
+ if (isImageOnlyMessage(chatItem, shouldHideAutoImageCaption)) {
+ return false;
+ }
+ return hasRenderableContent(chatItem.lxmf_message);
+}
+
/**
* @param {object} chatItem
* @param {(item: object) => boolean} shouldHideAutoImageCaption
@@ -37,7 +78,7 @@ export function hasRenderableContent(msg) {
export function isImageOnlyMessage(chatItem, shouldHideAutoImageCaption) {
const msg = chatItem.lxmf_message;
if (!msg.fields?.image) return false;
- if (msg.fields?.audio || msg.fields?.file_attachments) return false;
+ if (msg.fields?.audio || hasFileAttachments(msg)) return false;
const content = (msg.content || "").trim();
if (content && !shouldHideAutoImageCaption(chatItem)) return false;
if (msg.reply_to_hash) return false;

diff --git a/meshchatx/src/frontend/components/messages/messageListVirtual.js b/meshchatx/src/frontend/components/messages/messageListVirtual.js
index 023c20eb..2c000bc0 100644
--- a/meshchatx/src/frontend/components/messages/messageListVirtual.js
+++ b/meshchatx/src/frontend/components/messages/messageListVirtual.js
@@ -28,7 +28,27 @@ export function estimateGroupHeight(entry) {
if (entry.type === "imageGroup") {
return 340;
}
- return 120;
+ const msg = entry.chatItem?.lxmf_message;
+ const fields = msg?.fields;
+ if (fields?.image && !fields?.file_attachments?.length && !fields?.audio) {
+ return 280;
+ }
+ let height = 88;
+ const content = (msg?.content || "").trim();
+ if (content) {
+ height += Math.min(160, Math.ceil(content.length / 40) * 18);
+ }
+ const fileCount = Array.isArray(fields?.file_attachments) ? fields.file_attachments.length : 0;
+ if (fileCount > 0) {
+ height += fileCount * 56;
+ }
+ if (fields?.audio) {
+ height += 72;
+ }
+ if (fields?.telemetry || fields?.telemetry_stream || fields?.commands?.length) {
+ height += 48;
+ }
+ return height;
}
/**

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
index 7d5d02d7..1eee9116 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkBrowser.vue
@@ -34,7 +34,7 @@
<button
type="button"
class="flex items-center justify-center w-9 shrink-0 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
- :title="$t('nomadnet.new_tab')"
+ :title="$t('nomadnet.new_tab_shortcut')"
@click="addTab()"
>
<MaterialDesignIcon icon-name="plus" class="size-5" />
@@ -47,9 +47,11 @@
v-show="tab.id === activeTabId"
:key="tab.id"
embedded
+ :tabs-enabled="tabsEnabled"
:destination-hash="tab.destinationHash"
:initial-path="tab.initialPath"
@navigate="onTabNavigate(tab.id, $event)"
+ @open-node="onOpenNode"
@close-tab="closeTab(tab.id)"
/>
</div>
@@ -111,6 +113,7 @@ export default {
},
mounted() {
this.setupViewportWatcher();
+ window.addEventListener("keydown", this.handleKeydown, true);
const initialHash = (this.destinationHash || this.$route?.params?.destinationHash || "").trim();
const initialPath = this.$route?.query?.path || null;
@@ -121,6 +124,7 @@ export default {
},
beforeUnmount() {
this.teardownViewportWatcher();
+ window.removeEventListener("keydown", this.handleKeydown, true);
},
methods: {
setupViewportWatcher() {
@@ -151,7 +155,7 @@ export default {
this.mediaQuery = null;
this.mediaQueryListener = null;
},
- addTab(destinationHash = "", initialPath = null, title = null) {
+ addTab(destinationHash = "", initialPath = null, title = null, activate = true) {
const id = this.nextTabId++;
this.tabs.push({
id,
@@ -160,10 +164,91 @@ export default {
path: initialPath || null,
title: title || null,
});
- this.activeTabId = id;
- this.syncRoute();
+ if (activate) {
+ this.activeTabId = id;
+ this.syncRoute();
+ }
return id;
},
+ onOpenNode(payload) {
+ this.addTab(
+ payload?.destinationHash || "",
+ payload?.pagePath || null,
+ payload?.title || null,
+ payload?.activate !== false
+ );
+ },
+ selectRelativeTab(offset) {
+ if (this.tabs.length < 2) {
+ return;
+ }
+ const index = this.tabs.findIndex((tab) => tab.id === this.activeTabId);
+ if (index === -1) {
+ return;
+ }
+ const nextIndex = (index + offset + this.tabs.length) % this.tabs.length;
+ this.selectTab(this.tabs[nextIndex].id);
+ },
+ selectTabByIndex(index) {
+ if (index >= 0 && index < this.tabs.length) {
+ this.selectTab(this.tabs[index].id);
+ }
+ },
+ handleKeydown(event) {
+ if (!this.tabsEnabled || this.$route?.name !== "nomadnetwork") {
+ return;
+ }
+
+ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
+ const mod = isMac ? event.metaKey : event.ctrlKey;
+ const hasModifier = event.ctrlKey || event.metaKey || event.altKey;
+ const isInput =
+ ["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName) ||
+ document.activeElement?.isContentEditable;
+ if (isInput && !hasModifier) {
+ return;
+ }
+
+ const key = event.key.toLowerCase();
+
+ if (mod && key === "t") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.addTab();
+ return;
+ }
+ if (mod && key === "w") {
+ event.preventDefault();
+ event.stopPropagation();
+ if (this.activeTabId != null) {
+ this.closeTab(this.activeTabId);
+ }
+ return;
+ }
+ if (event.ctrlKey && key === "tab") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.selectRelativeTab(event.shiftKey ? -1 : 1);
+ return;
+ }
+ if (event.ctrlKey && key === "pageup") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.selectRelativeTab(-1);
+ return;
+ }
+ if (event.ctrlKey && key === "pagedown") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.selectRelativeTab(1);
+ return;
+ }
+ if (mod && key >= "1" && key <= "9") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.selectTabByIndex(parseInt(key, 10) - 1);
+ }
+ },
restoreTabs(routeHash, routePath) {
const saved = loadNomadTabs();
if (!saved || saved.tabs.length === 0) {

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 1bcdd9c8..700835da 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -410,6 +410,7 @@
]"
:style="nodeContainerShellStyle"
@click.capture="onElementClick"
+ @auxclick.capture="onElementClick"
>
<!-- archived version notice -->
<div
@@ -613,13 +614,17 @@ export default {
type: Boolean,
default: false,
},
+ tabsEnabled: {
+ type: Boolean,
+ default: false,
+ },
initialPath: {
type: String,
required: false,
default: null,
},
},
- emits: ["navigate", "close-tab"],
+ emits: ["navigate", "open-node", "close-tab"],
data() {
return {
GlobalState,
@@ -1144,6 +1149,34 @@ export default {
(b) => b.destination_hash === identityHash
);
},
+ getLinkNavOptions(event) {
+ const modifierClick = event.ctrlKey || event.metaKey;
+ const middleClick = event.button === 1;
+ return {
+ forceNewTab: modifierClick || middleClick,
+ activate: !modifierClick && !middleClick,
+ };
+ },
+ shouldOpenInNewTab(destinationHash, navOptions = {}) {
+ if (!this.embedded || !this.tabsEnabled) {
+ return false;
+ }
+ if (navOptions.forceNewTab) {
+ return true;
+ }
+ if (!destinationHash || !this.destinationHash) {
+ return false;
+ }
+ return destinationHash !== this.destinationHash;
+ },
+ emitOpenNode(destinationHash, pagePath, title = null, navOptions = {}) {
+ this.$emit("open-node", {
+ destinationHash,
+ pagePath,
+ title,
+ activate: navOptions.activate !== false,
+ });
+ },
onElementClick(event) {
const nomadLink = event.target.closest("a.nomadnet-link[data-nomadnet-url]");
if (nomadLink) {
@@ -1151,7 +1184,7 @@ export default {
event.stopPropagation();
const url = nomadLink.getAttribute("data-nomadnet-url");
if (url) {
- this.onNodePageUrlClick(url);
+ this.onNodePageUrlClick(url, null, true, false, this.getLinkNavOptions(event));
}
return;
}
@@ -1186,7 +1219,7 @@ export default {
const destination = element.getAttribute("data-destination");
const fields = element.getAttribute("data-fields");
- this.onNodePageUrlClick(destination, fields);
+ this.onNodePageUrlClick(destination, fields, true, false, this.getLinkNavOptions(event));
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
@@ -1606,7 +1639,24 @@ export default {
// navigate to the url
await this.onNodePageUrlClick(url);
},
- async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
+ async loadNodePage(
+ destinationHash,
+ pagePath,
+ fieldData = null,
+ addToHistory = true,
+ loadFromCache = true,
+ navOptions = {}
+ ) {
+ if (this.shouldOpenInNewTab(destinationHash, navOptions)) {
+ this.emitOpenNode(
+ destinationHash,
+ pagePath,
+ this.selectedNode?.custom_display_name || this.selectedNode?.display_name || null,
+ navOptions
+ );
+ return;
+ }
+
// update current route (skipped while embedded; the browser shell owns routing)
if (this.embedded) {
this.$emit("navigate", {
@@ -2030,7 +2080,7 @@ export default {
// unsupported url
return null;
},
- async onNodePageUrlClick(url, options = null, addToHistory = true, useCache = false) {
+ async onNodePageUrlClick(url, options = null, addToHistory = true, useCache = false, navOptions = {}) {
let fieldData = [];
if (options === "*") {
@@ -2219,7 +2269,7 @@ export default {
};
// navigate to node page
- this.loadNodePage(destinationHash, parsedUrl.path, fieldData, addToHistory, useCache);
+ this.loadNodePage(destinationHash, parsedUrl.path, fieldData, addToHistory, useCache, navOptions);
return;
}
@@ -2233,10 +2283,17 @@ export default {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
onNodeClick: function (node) {
- // update selected node
- this.selectedNode = node;
+ if (this.shouldOpenInNewTab(node.destination_hash, {})) {
+ this.emitOpenNode(
+ node.destination_hash,
+ this.defaultNodePagePath,
+ node.custom_display_name || node.display_name || null,
+ { activate: true }
+ );
+ return;
+ }
- // load default node page
+ this.selectedNode = node;
this.loadNodePage(node.destination_hash, this.defaultNodePagePath);
},
async onRenameFavourite(favourite) {

diff --git a/meshchatx/src/frontend/components/relay/RelayChatPage.vue b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
index bded8550..564c0247 100644
--- a/meshchatx/src/frontend/components/relay/RelayChatPage.vue
+++ b/meshchatx/src/frontend/components/relay/RelayChatPage.vue
@@ -1022,7 +1022,12 @@
@select="onHubIconPicked"
/>
- <ContextMenuPanel :show="sidebarMenu.show" :x="sidebarMenu.x" :y="sidebarMenu.y" @click="closeSidebarMenu">
+ <ContextMenuPanel
+ v-click-outside="{ handler: closeSidebarMenu, capture: true }"
+ :show="sidebarMenu.show"
+ :x="sidebarMenu.x"
+ :y="sidebarMenu.y"
+ >
<ContextMenuItem v-if="!sidebarMenu.hub" @click="openAddHubFromMenu">
{{ $t("relay_chat.ctx_add_hub") }}
</ContextMenuItem>
@@ -1052,7 +1057,12 @@
</template>
</ContextMenuPanel>
- <ContextMenuPanel :show="messageMenu.show" :x="messageMenu.x" :y="messageMenu.y" @click="closeMessageMenu">
+ <ContextMenuPanel
+ v-click-outside="{ handler: closeMessageMenu, capture: true }"
+ :show="messageMenu.show"
+ :x="messageMenu.x"
+ :y="messageMenu.y"
+ >
<ContextMenuItem
v-if="messageMenu.msg && canQuoteMessage(messageMenu.msg)"
@click="replyWithQuoteFromMenu"

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 18c92c53..0b1e30fe 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -1302,6 +1302,26 @@
</label>
</div>
+ <div
+ class="flex items-start gap-3 rounded-xl border border-gray-200 dark:border-zinc-700 px-3 py-2.5"
+ >
+ <input
+ id="outbound-transfer-progress-enabled"
+ type="checkbox"
+ class="mt-1 rounded-sm border-gray-300 dark:border-zinc-600"
+ :checked="GlobalState.outboundTransferProgressEnabled"
+ @change="onOutboundTransferProgressEnabledChange"
+ />
+ <label for="outbound-transfer-progress-enabled" class="min-w-0 cursor-pointer">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ {{ $t("app.outbound_transfer_progress_enabled") }}
+ </div>
+ <div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
+ {{ $t("app.outbound_transfer_progress_enabled_description") }}
+ </div>
+ </label>
+ </div>
+
<div
class="flex items-start gap-3 rounded-xl border border-gray-200 dark:border-zinc-700 px-3 py-2.5"
>
@@ -3521,6 +3541,15 @@ export default {
// ignore
}
},
+ onOutboundTransferProgressEnabledChange(event) {
+ const checked = event.target.checked;
+ GlobalState.outboundTransferProgressEnabled = checked;
+ try {
+ localStorage.setItem("meshchatx_outbound_transfer_progress_enabled", checked ? "true" : "false");
+ } catch {
+ // ignore
+ }
+ },
onMessageTimestampGroupingChange(event) {
const checked = event.target.checked;
GlobalState.messageTimestampGroupingEnabled = checked;

diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js
index f517864d..d5c0dcf1 100644
--- a/meshchatx/src/frontend/js/GlobalState.js
+++ b/meshchatx/src/frontend/js/GlobalState.js
@@ -6,6 +6,7 @@ const globalState = reactive({
authEnabled: false,
authenticated: false,
detailedOutboundSendStatus: false,
+ outboundTransferProgressEnabled: true,
messageTimestampGroupingEnabled: true,
unreadConversationsCount: 0,
relayChatUnreadCount: 0,

diff --git a/meshchatx/src/frontend/js/Utils.js b/meshchatx/src/frontend/js/Utils.js
index f8f6ed21..f294231b 100644
--- a/meshchatx/src/frontend/js/Utils.js
+++ b/meshchatx/src/frontend/js/Utils.js
@@ -176,7 +176,7 @@ class Utils {
const decimals = 0;
const sizes = ["bps", "kbps", "Mbps", "Gbps", "Tbps", "Pbps", "Ebps", "Zbps", "Ybps"];
- const i = Math.floor(Math.log(bits) / Math.log(k));
+ const i = Math.max(0, Math.floor(Math.log(bits) / Math.log(k)));
return parseFloat((bits / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
}
@@ -190,11 +190,73 @@ class Utils {
const decimals = 1;
const sizes = ["B/s", "KB/s", "MB/s", "GB/s", "TB/s", "PB/s", "EB/s", "ZB/s", "YB/s"];
- const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
+ const i = Math.max(0, Math.floor(Math.log(bytesPerSecond) / Math.log(k)));
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
}
+ static formatCountupDuration(totalSeconds) {
+ const seconds = Math.max(0, Math.floor(Number(totalSeconds) || 0));
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const remainingSeconds = seconds % 60;
+ if (hours > 0) {
+ return `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`;
+ }
+ return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
+ }
+
+ static lxmfMessageTransferTotalBytes(lxmfMessage, base64ByteLength = null) {
+ if (!lxmfMessage) {
+ return 0;
+ }
+
+ const decodeBase64Length = (value) => {
+ if (!value || typeof base64ByteLength !== "function") {
+ return 0;
+ }
+ return base64ByteLength(value);
+ };
+
+ let total = 0;
+ const content = lxmfMessage.content || "";
+ if (content) {
+ total += new TextEncoder().encode(content).length;
+ }
+
+ const fields = lxmfMessage.fields || {};
+ const image = fields.image;
+ if (image) {
+ if (image.image_size != null) {
+ total += Number(image.image_size) || 0;
+ } else if (image.image_bytes) {
+ total += decodeBase64Length(image.image_bytes);
+ }
+ }
+
+ const audio = fields.audio;
+ if (audio) {
+ if (audio.audio_size != null) {
+ total += Number(audio.audio_size) || 0;
+ } else if (audio.audio_bytes) {
+ total += decodeBase64Length(audio.audio_bytes);
+ }
+ }
+
+ const fileAttachments = fields.file_attachments;
+ if (Array.isArray(fileAttachments)) {
+ for (const file of fileAttachments) {
+ if (file?.file_size != null) {
+ total += Number(file.file_size) || 0;
+ } else if (file?.file_bytes) {
+ total += decodeBase64Length(file.file_bytes);
+ }
+ }
+ }
+
+ return total;
+ }
+
static formatFrequency(hz) {
const n = Number(hz);
if (!Number.isFinite(n) || n <= 0) {

diff --git a/tests/backend/test_propagation_sync_cancel.py b/tests/backend/test_propagation_sync_cancel.py
index 5eb31f1e..0306118e 100644
--- a/tests/backend/test_propagation_sync_cancel.py
+++ b/tests/backend/test_propagation_sync_cancel.py
@@ -90,3 +90,88 @@ async def test_sync_propagation_nodes_returns_before_request_thread_finishes():
request_finished.set()
await asyncio.sleep(0.05)
+
+
+@pytest.mark.asyncio
+async def test_sync_propagation_nodes_skips_without_outbound_node():
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ ctx = MagicMock()
+ router = MagicMock()
+ router.PR_IDLE = PR_IDLE
+ router.PR_COMPLETE = LXMF.LXMRouter.PR_COMPLETE
+ router.propagation_transfer_state = PR_IDLE
+ router.get_outbound_propagation_node.return_value = None
+ ctx.message_router = router
+ ctx.config = MagicMock()
+
+ started = await app.sync_propagation_nodes(context=ctx, force=False)
+
+ assert started is False
+ ctx.config.lxmf_preferred_propagation_node_last_synced_at.set.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_sync_propagation_nodes_restarts_after_complete_state():
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ ctx = MagicMock()
+ router = MagicMock()
+ router.PR_IDLE = PR_IDLE
+ router.PR_COMPLETE = LXMF.LXMRouter.PR_COMPLETE
+ router.propagation_transfer_state = LXMF.LXMRouter.PR_COMPLETE
+ router.get_outbound_propagation_node.return_value = b"\x22" * 16
+ router.propagation_destination = MagicMock(hash=b"\x11" * 16)
+ ctx.message_router = router
+ ctx.identity = MagicMock()
+ ctx.config = MagicMock()
+ ctx.database = MagicMock()
+ ctx.database.messages.count_lxmf_messages.return_value = 0
+ ctx.database.messages.count_lxmf_messages_by_state.return_value = 0
+
+ with (
+ patch.object(app, "_begin_propagation_sync_metrics"),
+ patch.object(app, "send_config_to_websocket_clients", return_value=None),
+ patch.object(app, "_request_propagation_node_messages", return_value=None),
+ ):
+ started = await app.sync_propagation_nodes(context=ctx, force=False)
+
+ assert started is True
+ assert router.propagation_transfer_state == PR_IDLE
+
+
+@pytest.mark.asyncio
+async def test_announce_sync_propagation_nodes_clears_on_failure_state():
+ app = ReticulumMeshChat.__new__(ReticulumMeshChat)
+ app.running = True
+ ctx = MagicMock()
+ ctx.running = True
+ ctx.session_id = "session-1"
+ router = MagicMock()
+ router.PR_IDLE = PR_IDLE
+ router.PR_COMPLETE = LXMF.LXMRouter.PR_COMPLETE
+ router.PR_PATH_REQUESTED = PR_PATH_REQUESTED
+ router.PR_FAILED = LXMF.LXMRouter.PR_FAILED
+ router.get_outbound_propagation_node.return_value = b"\x22" * 16
+ router.propagation_transfer_state = LXMF.LXMRouter.PR_FAILED
+ ctx.message_router = router
+ ctx.config = MagicMock()
+ ctx.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds.get.return_value = 3600
+ ctx.config.lxmf_preferred_propagation_node_last_synced_at.get.return_value = 0
+
+ loop_calls = {"count": 0}
+
+ async def fake_sleep(_seconds):
+ loop_calls["count"] += 1
+ if loop_calls["count"] >= 2:
+ ctx.running = False
+
+ with (
+ patch.object(app, "sync_propagation_nodes", return_value=True),
+ patch.object(app, "stop_propagation_node_sync") as mock_stop,
+ patch.object(app, "send_config_to_websocket_clients", return_value=None),
+ patch("meshchatx.meshchat.asyncio.sleep", side_effect=fake_sleep),
+ patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=False),
+ ):
+ await app.announce_sync_propagation_nodes("session-1", context=ctx)
+
+ mock_stop.assert_called_once_with(context=ctx)
+ assert router.propagation_transfer_state == PR_IDLE

diff --git a/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js b/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js
index 9f44a8c0..1f98fae0 100644
--- a/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js
+++ b/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js
@@ -13,6 +13,7 @@ describe("ConversationViewer outbound propagation status", () => {
GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
GlobalState.config.warn_on_stranger_links = true;
GlobalState.detailedOutboundSendStatus = true;
+ GlobalState.outboundTransferProgressEnabled = true;
WebSocketConnection.connect();
axiosMock = {
@@ -29,6 +30,7 @@ describe("ConversationViewer outbound propagation status", () => {
afterEach(() => {
GlobalState.detailedOutboundSendStatus = false;
+ GlobalState.outboundTransferProgressEnabled = true;
delete window.api;
WebSocketConnection.destroy();
});
@@ -88,6 +90,36 @@ describe("ConversationViewer outbound propagation status", () => {
expect(wrapper.vm.outboundSendingProgressLabel(null)).toBeNull();
});
+ it("showOutboundTransferProgress respects the settings toggle", () => {
+ const wrapper = mountViewer();
+ const message = { state: "sending", progress: 25 };
+ expect(wrapper.vm.showOutboundTransferProgress(message)).toBe(true);
+ GlobalState.outboundTransferProgressEnabled = false;
+ expect(wrapper.vm.showOutboundTransferProgress(message)).toBe(false);
+ });
+
+ it("outboundTransferStatsLabel includes speed, hops, and elapsed time", () => {
+ const wrapper = mountViewer();
+ const createdAt = new Date(Date.now() - 65000).toISOString();
+ wrapper.vm.sendStatusUiMs = Date.now();
+ const stats = wrapper.vm.outboundTransferStatsLabel(
+ {
+ state: "sending",
+ progress: 50,
+ content: "hello",
+ path_hops_at_send: 3,
+ created_at: createdAt,
+ },
+ {
+ created_at: createdAt,
+ }
+ );
+ expect(stats).toContain("B/s");
+ expect(stats).not.toContain("bps");
+ expect(stats).toContain("messages.transfer_progress_hops");
+ expect(stats).toMatch(/1:0[45]/);
+ });
+
it("outboundSendingStatusTooltip uses propagation pending strings for propagated method", () => {
const wrapper = mountViewer();
const withProgress = wrapper.vm.outboundSendingStatusTooltip({

diff --git a/tests/frontend/NomadNetworkBrowser.test.js b/tests/frontend/NomadNetworkBrowser.test.js
index 79c97245..381313ef 100644
--- a/tests/frontend/NomadNetworkBrowser.test.js
+++ b/tests/frontend/NomadNetworkBrowser.test.js
@@ -9,8 +9,9 @@ vi.mock("@/components/nomadnetwork/NomadNetworkPage.vue", () => ({
destinationHash: { type: String, default: "" },
initialPath: { type: String, default: null },
embedded: { type: Boolean, default: false },
+ tabsEnabled: { type: Boolean, default: false },
},
- emits: ["navigate", "close-tab"],
+ emits: ["navigate", "open-node", "close-tab"],
},
}));
@@ -25,14 +26,14 @@ const MaterialDesignIconStub = {
describe("NomadNetworkBrowser.vue", () => {
let routerReplace;
- const mountBrowser = (props = {}, route = { params: {}, query: {} }) => {
+ const mountBrowser = (props = {}, route = { name: "nomadnetwork", params: {}, query: {} }) => {
routerReplace = vi.fn(() => Promise.resolve());
return mount(NomadNetworkBrowser, {
props,
global: {
mocks: {
$t: (key) => key,
- $route: route,
+ $route: { name: "nomadnetwork", ...route },
$router: { replace: routerReplace },
},
stubs: {
@@ -206,4 +207,81 @@ describe("NomadNetworkBrowser.vue", () => {
expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(2);
expect(wrapper.findComponent({ name: "NomadNetworkPage" }).props("embedded")).toBe(true);
});
+
+ it("onOpenNode creates and activates a new tab for the node", () => {
+ const wrapper = mountBrowser();
+ const first = wrapper.vm.activeTabId;
+ wrapper.vm.onOpenNode({
+ destinationHash: "g".repeat(32),
+ pagePath: "/page/index.mu",
+ title: "Remote Node",
+ activate: true,
+ });
+ expect(wrapper.vm.tabs).toHaveLength(2);
+ expect(wrapper.vm.activeTabId).not.toBe(first);
+ expect(wrapper.vm.activeTab.destinationHash).toBe("g".repeat(32));
+ expect(wrapper.vm.activeTab.initialPath).toBe("/page/index.mu");
+ expect(wrapper.vm.activeTab.title).toBe("Remote Node");
+ });
+
+ it("onOpenNode can open a background tab without switching focus", () => {
+ const wrapper = mountBrowser();
+ const first = wrapper.vm.activeTabId;
+ wrapper.vm.onOpenNode({
+ destinationHash: "h".repeat(32),
+ pagePath: "/page/index.mu",
+ title: "Background",
+ activate: false,
+ });
+ expect(wrapper.vm.tabs).toHaveLength(2);
+ expect(wrapper.vm.activeTabId).toBe(first);
+ });
+
+ it("Ctrl+T opens a new tab when tabs are enabled", () => {
+ const wrapper = mountBrowser();
+ const before = wrapper.vm.tabs.length;
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "t", ctrlKey: true, bubbles: true, cancelable: true })
+ );
+ expect(wrapper.vm.tabs).toHaveLength(before + 1);
+ });
+
+ it("Ctrl+W closes the active tab when tabs are enabled", () => {
+ const wrapper = mountBrowser();
+ wrapper.vm.addTab("i".repeat(32));
+ expect(wrapper.vm.tabs).toHaveLength(2);
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "w", ctrlKey: true, bubbles: true, cancelable: true })
+ );
+ expect(wrapper.vm.tabs).toHaveLength(1);
+ });
+
+ it("Ctrl+Tab cycles to the next tab", () => {
+ const wrapper = mountBrowser();
+ const first = wrapper.vm.activeTabId;
+ const second = wrapper.vm.addTab("j".repeat(32));
+ wrapper.vm.activeTabId = first;
+
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Tab", ctrlKey: true, bubbles: true, cancelable: true })
+ );
+ expect(wrapper.vm.activeTabId).toBe(second);
+ });
+
+ it("Ctrl+1 switches to the first tab", () => {
+ const wrapper = mountBrowser();
+ const first = wrapper.vm.tabs[0].id;
+ wrapper.vm.addTab("k".repeat(32));
+
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "1", ctrlKey: true, bubbles: true, cancelable: true })
+ );
+ expect(wrapper.vm.activeTabId).toBe(first);
+ });
+
+ it("passes tabsEnabled to embedded NomadNetworkPage instances", async () => {
+ const wrapper = mountBrowser();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.findComponent({ name: "NomadNetworkPage" }).props("tabsEnabled")).toBe(true);
+ });
});

diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js
index ee29425b..0f2da83d 100644
--- a/tests/frontend/SettingsPage.config-persistence.test.js
+++ b/tests/frontend/SettingsPage.config-persistence.test.js
@@ -552,6 +552,17 @@ describe("SettingsPage — visualiser display prefs (localStorage + emitter)", (
expect(GlobalState.detailedOutboundSendStatus).toBe(false);
expect(localStorage.getItem("meshchatx_detailed_outbound_send_status")).toBe("false");
});
+
+ it("onOutboundTransferProgressEnabledChange updates GlobalState and localStorage", async () => {
+ localStorage.removeItem("meshchatx_outbound_transfer_progress_enabled");
+ const w = await mountSettingsPage(api);
+ await w.vm.onOutboundTransferProgressEnabledChange({ target: { checked: false } });
+ expect(GlobalState.outboundTransferProgressEnabled).toBe(false);
+ expect(localStorage.getItem("meshchatx_outbound_transfer_progress_enabled")).toBe("false");
+ await w.vm.onOutboundTransferProgressEnabledChange({ target: { checked: true } });
+ expect(GlobalState.outboundTransferProgressEnabled).toBe(true);
+ expect(localStorage.getItem("meshchatx_outbound_transfer_progress_enabled")).toBe("true");
+ });
});
describe("SettingsPage — maintenance, exports, telemetry trust, RNS reload", () => {

diff --git a/tests/frontend/TutorialModalMigration.test.js b/tests/frontend/TutorialModalMigration.test.js
index e6273df9..08d2c5d2 100644
--- a/tests/frontend/TutorialModalMigration.test.js
+++ b/tests/frontend/TutorialModalMigration.test.js
@@ -571,6 +571,202 @@ describe("TutorialModal getting started migration", () => {
wrapper.unmount();
});
+ it("hides footer Continue on connection and bootstrap steps", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+
+ wrapper.vm.currentStep = 3;
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.showFooterContinue).toBe(false);
+
+ wrapper.vm.currentStep = 4;
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.showFooterContinue).toBe(false);
+
+ wrapper.vm.currentStep = 5;
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.showFooterContinue).toBe(true);
+
+ wrapper.unmount();
+ });
+
+ it("nextStep on connection step without a mode shows warning and does not advance", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 3;
+ wrapper.vm.connectionMode = null;
+ wrapper.vm.nextStep();
+
+ expect(wrapper.vm.currentStep).toBe(3);
+ expect(ToastUtils.warning).toHaveBeenCalledWith(en.tutorial.connect_mode_required);
+
+ wrapper.unmount();
+ });
+
+ it("useLocalMode stays on connection step when Reticulum reload fails", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/reticulum/interfaces/add") {
+ return Promise.resolve({ data: { message: "added" } });
+ }
+ if (url === "/api/v1/reticulum/reload") {
+ return Promise.reject({ response: { data: { error: "reload failed" } } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 3;
+ await wrapper.vm.useLocalMode();
+ await flushPromises();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/interfaces/add", {
+ name: "Local Network",
+ type: "AutoInterface",
+ enabled: true,
+ });
+ expect(wrapper.vm.currentStep).toBe(3);
+ expect(ToastUtils.error).toHaveBeenCalledWith(en.tutorial.failed_reload_rns);
+
+ wrapper.unmount();
+ });
+
+ it("confirmBootstraps reload failure keeps user on bootstrap step", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/community-interfaces") {
+ return Promise.resolve({
+ data: {
+ interfaces: [
+ {
+ name: "Test TCP",
+ type: "TCPClientInterface",
+ target_host: "1.2.3.4",
+ target_port: 4242,
+ },
+ ],
+ },
+ });
+ }
+ return discoveryApiHandlers({ show_choice: false })(url);
+ });
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/reticulum/interfaces/add") {
+ return Promise.resolve({ data: { message: "added" } });
+ }
+ if (url === "/api/v1/reticulum/reload") {
+ return Promise.reject({ response: { data: { error: "reload failed" } } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 4;
+ wrapper.vm.connectionMode = "discovery";
+ wrapper.vm.selectedBootstrapKeys = ["comm:Test TCP"];
+ await wrapper.vm.confirmBootstraps();
+ await flushPromises();
+
+ expect(wrapper.vm.currentStep).toBe(4);
+ expect(ToastUtils.error).toHaveBeenCalledWith(en.tutorial.failed_reload_rns);
+
+ wrapper.unmount();
+ });
+
+ it("finishTutorial blocks when pending interface reload fails", async () => {
+ const GlobalState = (await import("../../meshchatx/src/frontend/js/GlobalState.js")).default;
+ GlobalState.hasPendingInterfaceChanges = true;
+
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/reticulum/reload") {
+ return Promise.reject({ response: { data: { error: "reload failed" } } });
+ }
+ if (url === "/api/v1/app/tutorial/seen") {
+ return Promise.resolve({ data: {} });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.visible = true;
+ wrapper.vm.currentStep = wrapper.vm.totalSteps;
+ await wrapper.vm.finishTutorial();
+ await flushPromises();
+
+ expect(wrapper.vm.visible).toBe(true);
+ expect(axiosMock.post).not.toHaveBeenCalledWith("/api/v1/app/tutorial/seen", expect.anything());
+
+ GlobalState.hasPendingInterfaceChanges = false;
+ wrapper.unmount();
+ });
+
it("finishTutorial keeps modal open and reports error when identity switch fails", async () => {
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
axiosMock.post.mockImplementation((url) => {

diff --git a/tests/frontend/Utils.test.js b/tests/frontend/Utils.test.js
index 9b462962..1a75ec38 100644
--- a/tests/frontend/Utils.test.js
+++ b/tests/frontend/Utils.test.js
@@ -66,6 +66,31 @@ describe("Utils.js", () => {
});
});
+ describe("formatCountupDuration", () => {
+ it("formats seconds and minutes", () => {
+ expect(Utils.formatCountupDuration(0)).toBe("0:00");
+ expect(Utils.formatCountupDuration(42)).toBe("0:42");
+ expect(Utils.formatCountupDuration(125)).toBe("2:05");
+ });
+
+ it("formats hours when needed", () => {
+ expect(Utils.formatCountupDuration(3661)).toBe("1:01:01");
+ });
+ });
+
+ describe("lxmfMessageTransferTotalBytes", () => {
+ it("sums content and attachment sizes", () => {
+ const total = Utils.lxmfMessageTransferTotalBytes({
+ content: "hello",
+ fields: {
+ audio: { audio_size: 1000 },
+ file_attachments: [{ file_size: 500 }],
+ },
+ });
+ expect(total).toBe(new TextEncoder().encode("hello").length + 1500);
+ });
+ });
+
describe("formatSeconds", () => {
it('formats "a second ago"', () => {
expect(Utils.formatSeconds(1)).toBe("a second ago");

diff --git a/tests/frontend/conversationMessageHelpers.test.js b/tests/frontend/conversationMessageHelpers.test.js
index 64f8738c..80a04427 100644
--- a/tests/frontend/conversationMessageHelpers.test.js
+++ b/tests/frontend/conversationMessageHelpers.test.js
@@ -2,6 +2,9 @@ import { describe, it, expect } from "vitest";
import {
isTelemetryOnly,
hasRenderableContent,
+ hasFileAttachments,
+ hasMessageBubble,
+ isFileOnlyMessage,
isImageOnlyMessage,
collectImageFilesFromDataTransfer,
extractClipboardImageFiles,
@@ -12,6 +15,26 @@ describe("conversationMessageHelpers", () => {
expect(hasRenderableContent({ content: " hi " })).toBe(true);
expect(hasRenderableContent({ content: "", fields: { image: {} } })).toBe(true);
expect(hasRenderableContent({ content: " ", fields: {} })).toBe(false);
+ expect(hasRenderableContent({ content: "", fields: { file_attachments: [] } })).toBe(false);
+ expect(hasRenderableContent({ content: "", fields: { file_attachments: [{ file_name: "a.zip" }] } })).toBe(
+ true
+ );
+ });
+
+ it("hasFileAttachments requires a non-empty array", () => {
+ expect(hasFileAttachments({ fields: { file_attachments: [{ file_name: "a.zip" }] } })).toBe(true);
+ expect(hasFileAttachments({ fields: { file_attachments: [] } })).toBe(false);
+ expect(hasFileAttachments({ fields: {} })).toBe(false);
+ });
+
+ it("isFileOnlyMessage matches attachment-only rows", () => {
+ const chatItem = {
+ lxmf_message: {
+ fields: { file_attachments: [{ file_name: "readme.txt" }] },
+ },
+ };
+ expect(isFileOnlyMessage(chatItem, () => false)).toBe(true);
+ expect(hasMessageBubble(chatItem, () => false)).toBe(true);
});
it("isTelemetryOnly when only telemetry fields", () => {

diff --git a/tests/frontend/messageListVirtual.test.js b/tests/frontend/messageListVirtual.test.js
index 4e21252b..f2fdd557 100644
--- a/tests/frontend/messageListVirtual.test.js
+++ b/tests/frontend/messageListVirtual.test.js
@@ -22,6 +22,30 @@ describe("messageListVirtual.js", () => {
);
});
+ it("estimateGroupHeight scales with file attachment count", () => {
+ const oneFile = estimateGroupHeight({
+ type: "single",
+ chatItem: {
+ lxmf_message: {
+ content: "",
+ fields: { file_attachments: [{ file_name: "a.zip" }] },
+ },
+ },
+ });
+ const threeFiles = estimateGroupHeight({
+ type: "single",
+ chatItem: {
+ lxmf_message: {
+ content: "",
+ fields: {
+ file_attachments: [{ file_name: "a.zip" }, { file_name: "b.zip" }, { file_name: "c.zip" }],
+ },
+ },
+ },
+ });
+ expect(threeFiles).toBeGreaterThan(oneFile);
+ });
+
it("findDisplayGroupIndexForMessageHash finds single and image group members", () => {
const groups = [
{ type: "single", key: "x", chatItem: { lxmf_message: { hash: "h1" } } },


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────